revert(admin): keep original admin layout with family replacing primary/secondary sources

This commit is contained in:
yeuimu
2026-08-28 14:35:34 +08:00
10 changed files with 124 additions and 1091 deletions
-7
View File
@@ -13,22 +13,17 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCascader: typeof import('element-plus/es')['ElCascader'] ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -46,12 +41,10 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
+115 -120
View File
@@ -4,7 +4,7 @@ import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
Plus, Edit, Delete, Search, Top, Refresh, Plus, Edit, Delete, Search, Top, Refresh,
FolderAdd, Aim, ArrowDown, FolderAdd, Aim, ArrowDown, QuestionFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
@@ -257,13 +257,14 @@ const leftTreeData = computed(() => {
}) })
function buildRightTree(tree: OriginGoodsTreeResponse) { function buildRightTree(tree: OriginGoodsTreeResponse) {
function mapOgs(ogs: any[], bucketId: string): any[] { function mapCat(node: any): any {
return ogs.map((og: any) => ({ const children = (node.children || []).map(mapCat)
const goods = (node.originGoods || []).map((og: any) => ({
id: 'og-' + og.id, id: 'og-' + og.id,
label: og.goodName, label: og.goodName,
isOG: true, isOG: true,
rawId: og.id, rawId: og.id,
parentId: bucketId, parentId: 'rc-' + node.categoryId,
goodName: og.goodName, goodName: og.goodName,
goodImage: og.goodImage, goodImage: og.goodImage,
goodPrice: og.goodPrice, goodPrice: og.goodPrice,
@@ -280,56 +281,12 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
familyName: og.familyName ?? null, familyName: og.familyName ?? null,
familyStale: og.familyStale ?? 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 { return {
id: 'rc-' + node.categoryId, id: 'rc-' + node.categoryId,
label: node.categoryName, label: node.categoryName,
configuredCount: node.configuredCount ?? 0, configuredCount: node.configuredCount ?? 0,
totalCount: node.totalCount ?? 0, totalCount: node.totalCount ?? 0,
children: [...children, ...familyNodes], children: [...children, ...goods],
} }
} }
rightTreeData.value = tree.tree.map(mapCat) rightTreeData.value = tree.tree.map(mapCat)
@@ -484,7 +441,8 @@ function onConfigCascaderChange(val: any) {
configForm.value.categoryId = val.length ? val[val.length - 1] : '' configForm.value.categoryId = val.length ? val[val.length - 1] : ''
} }
/** 配置时把勾选的兄弟链接与主链接归入同一族(无族则自动建族);失败不阻断原配置流程 */ /** 多对一合并的族支撑:勾选的兄弟链接与主链接静默归入同一族(无族则自动建族)
* 前端交互保持原「合并同名」流程不变;失败不阻断原配置流程 */
async function ensureFamilyMembership() { async function ensureFamilyMembership() {
const primaryId = String(configPrimaryId.value || configOG.value.rawId) const primaryId = String(configPrimaryId.value || configOG.value.rawId)
const checked = configChecked.value.filter((id) => id !== primaryId) const checked = configChecked.value.filter((id) => id !== primaryId)
@@ -675,34 +633,91 @@ function addCustomVariant() {
}) })
} }
// ─── Edit family (产品族) ─── // ─── Edit family(编辑弹窗的族成员管理,替代旧主源/副源) ───
const editFamilyId = ref<string>('') const editFamilyId = ref<string>('')
const editFamilyTouched = ref(false) const editFamilyCode = ref<string | null>(null)
const editFamilyOptions = ref<Array<{ id: string; familyCode: string | null; familyName: string }>>([]) const editFamilyMembers = ref<Array<{ id: string; goodName: string; goodImage: string | null }>>([])
const editFamilyLoading = ref(false) 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 ?? '' editFamilyId.value = family?.familyId ?? ''
editFamilyTouched.value = false editFamilyCode.value = family?.familyCode ?? null
editFamilyOptions.value = family?.familyId editFamilyMembers.value = []
? [{ id: family.familyId, familyCode: family.familyCode ?? null, familyName: family.familyName ?? '' }] familyMemberCount.value = null
: [] editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
if (editFamilyId.value) loadEditFamily()
} }
async function searchEditFamilies(q: string) { async function loadEditFamily() {
if (!q) return if (!editFamilyId.value) return
editFamilyLoading.value = true
try { try {
const res = await productFamiliesApi.list({ keyword: q, page: 1, pageSize: 30 }) const f = await productFamiliesApi.detail(editFamilyId.value)
editFamilyOptions.value = res.items.map((f) => ({ id: f.id, familyCode: f.familyCode, familyName: f.familyName })) 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 { } finally {
editFamilyLoading.value = false editFamilyLoaded.value = true
} }
} }
function onEditFamilyChange(v: string | null | undefined) { async function refreshAfterFamilyChange() {
editFamilyTouched.value = true if (!editGood.value) return
editFamilyId.value = v ?? '' 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) { async function openEdit(g: Good) {
@@ -846,10 +861,6 @@ async function handleEditSubmit() {
categoryId: Number(editForm.value.categoryId), categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number), tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
// 族变更:仅在用户改动过选择时提交(undefined = 不动,null = 脱离族)
...(editFamilyTouched.value
? { familyId: editFamilyId.value ? Number(editFamilyId.value) : null }
: {}),
} as any) } as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload) if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功') ElMessage.success('更新成功')
@@ -1741,18 +1752,6 @@ onMounted(() => loadAll())
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" /> <el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
<div v-else class="og-thumb og-thumb-placeholder" /> <div v-else class="og-thumb og-thumb-placeholder" />
<span class="og-name">{{ data.label }}</span> <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 <span
v-if="data.configuredCount > 0" v-if="data.configuredCount > 0"
class="og-badge og-badge--ok" class="og-badge og-badge--ok"
@@ -1789,12 +1788,8 @@ onMounted(() => loadAll())
@click.stop="openConfigFromRightTree(data)" @click.stop="openConfigFromRightTree(data)"
>配置</el-button> >配置</el-button>
</div> </div>
<div v-else class="og-cat-node" :class="{ 'og-family-node': data.isFamily }"> <div v-else class="og-cat-node">
<span class="og-cat-label">{{ data.isFamily ? '族' : '' }} {{ data.label }}</span> <span>{{ data.label }}</span>
<el-tag
v-if="data.isFamily && data.familyStale"
size="small" type="warning" class="og-family-stale"
>待处理</el-tag>
<span v-if="data.totalCount" class="og-cat-count"> <span v-if="data.totalCount" class="og-cat-count">
<template v-if="data.configuredCount < data.totalCount"> <template v-if="data.configuredCount < data.totalCount">
{{ 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-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-tag v-else>{{ configDropTarget?.label }}</el-tag>
</el-form-item> </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-box">
<div class="config-merge-tip">勾选同分类链接将与主链接一起归入同一产品族并集尺码/包装 + 五维价格矩阵主链接决定详情与跳转</div> <div class="config-merge-tip">勾选同分类下同名不同工厂/仓库原产品合并为一个商品主源决定价格与详情</div>
<div class="config-merge-primary"> <div class="config-merge-primary">
<el-radio-group v-model="configPrimaryId"> <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 v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
</el-radio-group> </el-radio-group>
</div> </div>
@@ -2008,34 +2003,35 @@ onMounted(() => loadAll())
>同步详情</el-button> >同步详情</el-button>
</div> </div>
<div v-if="!editIsCustom" class="edit-merged-box"> <div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">产品族合并新机制</div> <div class="edit-merged-title">
<div class="config-merge-tip" style="padding: 4px 0 8px"> 关联原产品族成员
多链接合并已由产品族承载同族链接自动合并尺码/包装并集与五维价格矩阵下方"所属族"可切换 <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>
<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> </div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px"> <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-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="图片"> <el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" /> <ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item> </el-form-item>
@@ -2447,9 +2443,6 @@ onMounted(() => loadAll())
background: linear-gradient(135deg, #f5f7fa, #e9ecef); background: linear-gradient(135deg, #f5f7fa, #e9ecef);
} }
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } .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-price { color: #909399; font-size: 12px; flex-shrink: 0; }
.og-badge { .og-badge {
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px; 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; display: flex; align-items: center; gap: 4px;
font-size: 12px; color: #909399; margin-bottom: 8px; 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-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item { .edit-merged-item {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -1,91 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { AutoGroupPreviewGroup } from '@/types'
import { productFamiliesApi } from '@/api/product-families'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'done'): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const loading = ref(false)
const applying = ref(false)
const groups = ref<AutoGroupPreviewGroup[]>([])
async function loadPreview() {
loading.value = true
try {
const res = await productFamiliesApi.autoGroup(false)
groups.value = res.groups
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '预览失败')
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) loadPreview()
},
)
const totalLinks = computed(() => groups.value.reduce((s, g) => s + g.memberCount, 0))
async function apply() {
applying.value = true
try {
const res = await productFamiliesApi.autoGroup(true)
ElMessage.success(`已创建 ${res.applied} 个族`)
visible.value = false
emit('done')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '成族失败')
} finally {
applying.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="自动成族(按 SDS 分类 = 产品模型)" width="760px">
<div v-loading="loading">
<el-alert
type="info"
:closable="false"
show-icon
:title="`共发现 ${groups.length} 个候选分组、${totalLinks} 条未归属链接`"
description="同 SDS 分类的链接将合并为一个族(跨工艺/物流/仓库/编码);应用后自动重算每个族的并集与价格矩阵,操作幂等。"
/>
<el-table :data="groups" size="small" border max-height="420" style="margin-top: 12px">
<el-table-column prop="familyCode" label="编码" width="110">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.familyCode ?? '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="familyName" label="族名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="memberCount" label="成员数" width="80" align="center" />
<el-table-column label="示例链接" min-width="260">
<template #default="{ row }">
<div v-for="n in row.sampleNames" :key="n" class="ag-sample">{{ n }}</div>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="applying" :disabled="!groups.length" @click="apply">
确认建族{{ groups.length }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.ag-sample { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
</style>
@@ -1,143 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { CustomMemberVariantInput } from '@/types'
import { productFamiliesApi } from '@/api/product-families'
import ImageUpload from '@/components/ImageUpload.vue'
const props = defineProps<{ modelValue: boolean; familyId: string | null }>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'created'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const submitting = ref(false)
const form = ref({
goodName: '',
goodImage: '',
logisticsLabel: '',
craftLabel: '',
skuCode: '',
warehouseLabel: '',
})
const variants = ref<CustomMemberVariantInput[]>([{ sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', price: 0 }])
function addVariant() {
variants.value.push({ sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', price: 0 })
}
function removeVariant(i: number) {
variants.value.splice(i, 1)
}
function reset() {
form.value = { goodName: '', goodImage: '', logisticsLabel: '', craftLabel: '', skuCode: '', warehouseLabel: '' }
variants.value = [{ sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', price: 0 }]
}
async function submit() {
if (!props.familyId) return
if (!form.value.goodName.trim() || !form.value.logisticsLabel.trim() || !form.value.craftLabel.trim()) {
ElMessage.warning('名称、物流、工艺为必填(价格矩阵归因依赖)')
return
}
const payload = variants.value.filter((v) => v.sku.trim())
if (!payload.length) {
ElMessage.warning('至少一条变体(含价格)')
return
}
submitting.value = true
try {
await productFamiliesApi.createCustomMember(props.familyId, {
goodName: form.value.goodName.trim(),
goodImage: form.value.goodImage || null,
logisticsLabel: form.value.logisticsLabel.trim(),
craftLabel: form.value.craftLabel.trim(),
skuCode: form.value.skuCode.trim() || null,
warehouseLabel: form.value.warehouseLabel.trim() || null,
variants: payload.map((v) => ({
sku: v.sku.trim(),
sizeId: v.sizeId?.trim() || null,
sizeName: v.sizeName?.trim() || null,
colorId: v.colorId?.trim() || null,
colorName: v.colorName?.trim() || null,
price: v.price,
})),
})
ElMessage.success('自定义成员已创建,族已重算')
visible.value = false
reset()
emit('created')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="新建自定义成员(人工商品)" width="720px" destroy-on-close @closed="reset">
<el-form label-width="90px">
<el-form-item label="商品名" required>
<el-input v-model="form.goodName" placeholder="如 美国(海运)180g纯棉T恤-DG001-烫画" />
</el-form-item>
<el-form-item label="主图">
<ImageUpload v-model="form.goodImage" label="上传图片" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="物流" required>
<el-input v-model="form.logisticsLabel" placeholder="如 海运(矩阵归因必填)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="工艺" required>
<el-input v-model="form.craftLabel" placeholder="如 烫画 / 双面印花(必填)" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="SKU 编码">
<el-input v-model="form.skuCode" placeholder="可留空" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="仓库">
<el-input v-model="form.warehouseLabel" placeholder="可留空" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="变体价格">
<div class="cmd-variants">
<div v-for="(v, i) in variants" :key="i" class="cmd-variant-row">
<el-input v-model="v.sku" placeholder="SKU" style="width: 150px" />
<el-input v-model="v.sizeId" placeholder="尺码ID(size_S)" style="width: 120px" />
<el-input v-model="v.sizeName" placeholder="尺码名(S)" style="width: 80px" />
<el-input v-model="v.colorId" placeholder="颜色ID" style="width: 120px" />
<el-input v-model="v.colorName" placeholder="颜色名" style="width: 80px" />
<el-input-number v-model="v.price" :min="0.01" :precision="2" :controls="false" placeholder="价格" style="width: 100px" />
<el-button link type="danger" :icon="Delete" :disabled="variants.length <= 1" @click="removeVariant(i)" />
</div>
<el-button size="small" :icon="Plus" @click="addVariant">加一行</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">创建并入族</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.cmd-variants { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.cmd-variant-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
</style>
@@ -1,326 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Delete, Plus } from '@element-plus/icons-vue'
import type { ProductFamily } from '@/types'
import { productFamiliesApi } from '@/api/product-families'
import ImageUpload from '@/components/ImageUpload.vue'
import PriceOverridePanel from './PriceOverridePanel.vue'
import CustomMemberDialog from './CustomMemberDialog.vue'
const props = defineProps<{ modelValue: boolean; familyId: string | null }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'reload'): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const loading = ref(false)
const family = ref<ProductFamily | null>(null)
const customMemberVisible = ref(false)
const editForm = ref({ familyName: '', familyCode: '', familyImage: '', primaryOriginGoodId: '' })
const saving = ref(false)
const busy = ref(false)
const matrixSummary = computed(() => {
const m = family.value?.priceMatrix
if (!m) return null
const prices = m.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n))
return {
sizes: m.sizes.length,
colors: m.colors.length,
crafts: m.crafts.length,
logistics: m.logistics.length,
rows: m.rows.length,
minPrice: prices.length ? Math.min(...prices) : null,
}
})
async function load() {
if (!props.familyId) return
loading.value = true
try {
family.value = await productFamiliesApi.detail(props.familyId)
editForm.value = {
familyName: family.value.familyName,
familyCode: family.value.familyCode ?? '',
familyImage: family.value.familyImage ?? '',
primaryOriginGoodId: family.value.primaryOriginGoodId ?? '',
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '加载族详情失败')
} finally {
loading.value = false
}
}
watch(() => [props.modelValue, props.familyId], ([open]) => {
if (open) load()
})
async function saveCanonical() {
if (!family.value) return
saving.value = true
try {
await productFamiliesApi.patch(family.value.id, {
familyName: editForm.value.familyName.trim(),
familyCode: editForm.value.familyCode.trim(),
familyImage: editForm.value.familyImage || null,
primaryOriginGoodId: editForm.value.primaryOriginGoodId || null,
})
ElMessage.success('已保存')
await load()
emit('reload')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function toggleAutoManaged(v: boolean | string | number) {
if (!family.value) return
busy.value = true
try {
await productFamiliesApi.patch(family.value.id, { autoManaged: Boolean(v) })
ElMessage.success(v ? '已切回自动托管' : '已锁定(重算只标记待处理)')
await load()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '切换失败')
} finally {
busy.value = false
}
}
async function recompute() {
if (!family.value) return
busy.value = true
try {
family.value = await productFamiliesApi.recompute(family.value.id)
ElMessage.success('重算完成')
emit('reload')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '重算失败')
} finally {
busy.value = false
}
}
async function removeMember(id: string) {
if (!family.value) return
try {
await ElMessageBox.confirm('将该链接移出本族?并集与价格矩阵会重算。', '移除成员', { type: 'warning' })
} catch {
return
}
busy.value = true
try {
family.value = await productFamiliesApi.updateMembers(family.value.id, { removeOriginGoodIds: [id] })
ElMessage.success('已移除')
emit('reload')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '移除失败')
} finally {
busy.value = false
}
}
// 添加成员:远程搜索无族链接
const memberSearchKeyword = ref('')
const memberOptions = ref<Array<{ id: string; goodName: string }>>([])
const memberSearchLoading = ref(false)
async function searchMembers(q: string) {
if (!q) {
memberOptions.value = []
return
}
memberSearchLoading.value = true
try {
const res = await (await import('@/api/origin-goods')).originGoodsApi.getOriginGoodsList({
keyword: q,
page: 1,
pageSize: 20,
})
memberOptions.value = res.items.map((o: any) => ({ id: String(o.id), goodName: o.goodName ?? o.sdsGoodId }))
} finally {
memberSearchLoading.value = false
}
}
const memberAddIds = ref<string[]>([])
async function addMembers() {
if (!family.value || !memberAddIds.value.length) return
busy.value = true
try {
family.value = await productFamiliesApi.updateMembers(family.value.id, {
addOriginGoodIds: memberAddIds.value,
})
ElMessage.success('已添加')
memberAddIds.value = []
memberSearchKeyword.value = ''
emit('reload')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '添加失败')
} finally {
busy.value = false
}
}
</script>
<template>
<el-drawer v-model="visible" title="产品族详情" size="72%" destroy-on-close>
<div v-loading="loading || busy" class="fam-detail">
<template v-if="family">
<div class="fam-header">
<div class="fam-title">
<el-tag v-if="family.familyCode" type="info" size="small">{{ family.familyCode }}</el-tag>
<span class="fam-name">{{ family.familyName }}</span>
<el-tag v-if="family.stale" type="danger" size="small">待处理上游已变化</el-tag>
</div>
<div class="fam-actions">
<el-switch
:model-value="family.autoManaged"
active-text="自动托管"
inactive-text="锁定"
@change="toggleAutoManaged"
/>
<el-button type="primary" :icon="Refresh" @click="recompute">重算</el-button>
</div>
</div>
<el-collapse class="fam-collapse">
<el-collapse-item title="基本信息(canonical" name="base">
<el-form label-width="90px" class="fam-form">
<el-form-item label="族名称">
<el-input v-model="editForm.familyName" />
</el-form-item>
<el-form-item label="族编码">
<el-input v-model="editForm.familyCode" placeholder="如 DG001" />
</el-form-item>
<el-form-item label="主图">
<ImageUpload v-model="editForm.familyImage" label="上传图片" />
</el-form-item>
<el-form-item label="主链接">
<el-select v-model="editForm.primaryOriginGoodId" clearable filterable placeholder="canonical 详情与跳转兜底">
<el-option
v-for="m in family.originGoods"
:key="m.id"
:value="m.id"
:label="m.goodName"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveCanonical">保存</el-button>
</el-form-item>
</el-form>
</el-collapse-item>
<el-collapse-item :title="`成员链接(${family.originGoods.length}`" name="members">
<div class="fam-member-add">
<el-select
v-model="memberAddIds"
multiple
filterable
remote
clearable
placeholder="搜索无族链接(名称/ID"
:remote-method="searchMembers"
:loading="memberSearchLoading"
style="width: 420px"
>
<el-option v-for="o in memberOptions" :key="o.id" :value="o.id" :label="o.goodName" />
</el-select>
<el-button type="primary" plain :icon="Plus" :disabled="!memberAddIds.length" @click="addMembers">
添加成员
</el-button>
<el-button type="success" plain :icon="Plus" @click="customMemberVisible = true">
新建自定义成员
</el-button>
</div>
<el-table :data="family.originGoods" size="small" border>
<el-table-column prop="goodName" label="链接名" min-width="260" show-overflow-tooltip />
<el-table-column prop="logisticsLabel" label="物流" width="110">
<template #default="{ row }">{{ row.logisticsLabel ?? '—' }}</template>
</el-table-column>
<el-table-column prop="craftLabel" label="工艺" width="100">
<template #default="{ row }">{{ row.craftLabel ?? '—' }}</template>
</el-table-column>
<el-table-column prop="warehouseLabel" label="仓库" width="140">
<template #default="{ row }">{{ row.warehouseLabel ?? '—' }}</template>
</el-table-column>
<el-table-column label="来源" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.source === 'CUSTOM' ? 'warning' : 'info'" size="small">
{{ row.source === 'CUSTOM' ? '自建' : 'SDS' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="主链接" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.id === family.primaryOriginGoodId" type="success" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="70" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :icon="Delete" @click="removeMember(row.id)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
<el-collapse-item title="价格矩阵与人工改价" name="price">
<div v-if="matrixSummary" class="fam-matrix-summary">
<el-tag size="small">尺码 {{ matrixSummary.sizes }}</el-tag>
<el-tag size="small" type="success">颜色 {{ matrixSummary.colors }}</el-tag>
<el-tag size="small" type="warning">工艺 {{ matrixSummary.crafts }}</el-tag>
<el-tag size="small" type="info">物流 {{ matrixSummary.logistics }}</el-tag>
<el-tag size="small"> {{ matrixSummary.rows }} </el-tag>
<el-tag v-if="matrixSummary.minPrice !== null" size="small" type="danger">
起价 ¥{{ matrixSummary.minPrice }}
</el-tag>
</div>
<PriceOverridePanel v-if="family.priceMatrix" :family="family" @changed="load" />
<el-empty v-else description="暂无价格矩阵(请先重算)" :image-size="60" />
</el-collapse-item>
<el-collapse-item title="并集尺码表" name="size">
<el-table v-if="family.sizeChart?.rows?.length" :data="family.sizeChart.rows" size="small" border>
<el-table-column prop="sizeName" label="尺码" width="100">
<template #default="{ row }">{{ row.sizeName ?? row.sizeId }}</template>
</el-table-column>
<el-table-column
v-for="key in Object.keys(family.sizeChart.rows[0] ?? {}).filter((k) => k !== 'sizeId' && k !== 'sizeName')"
:key="key"
:prop="key"
:label="key"
/>
</el-table>
<el-empty v-else description="暂无尺码表" :image-size="60" />
</el-collapse-item>
</el-collapse>
</template>
</div>
<CustomMemberDialog
v-model="customMemberVisible"
:family-id="family?.id ?? null"
@created="load(); emit('reload')"
/>
</el-drawer>
</template>
<style scoped>
.fam-detail { min-height: 200px; }
.fam-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.fam-title { display: flex; align-items: center; gap: 8px; }
.fam-name { font-size: 16px; font-weight: 600; }
.fam-actions { display: flex; align-items: center; gap: 12px; }
.fam-collapse { border-top: none; }
.fam-form { max-width: 560px; }
.fam-member-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.fam-matrix-summary { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
</style>
@@ -1,188 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, MagicStick, Plus, Refresh } from '@element-plus/icons-vue'
import type { ProductFamily } from '@/types'
import { productFamiliesApi } from '@/api/product-families'
import FamilyDetailDrawer from './FamilyDetailDrawer.vue'
import AutoGroupDialog from './AutoGroupDialog.vue'
const loading = ref(false)
const items = ref<ProductFamily[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const drawerVisible = ref(false)
const currentId = ref<string | null>(null)
const autoGroupVisible = ref(false)
const createVisible = ref(false)
const createLoading = ref(false)
const createForm = ref({ familyName: '', familyCode: '' })
async function load() {
loading.value = true
try {
const res = await productFamiliesApi.list({
keyword: keyword.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
items.value = res.items
total.value = Number(res.total)
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '加载产品族失败')
} finally {
loading.value = false
}
}
function onSearch() {
page.value = 1
load()
}
function openDetail(row: ProductFamily) {
currentId.value = row.id
drawerVisible.value = true
}
async function submitCreate() {
if (!createForm.value.familyName.trim()) {
ElMessage.warning('请填写族名称')
return
}
createLoading.value = true
try {
await productFamiliesApi.create({
familyName: createForm.value.familyName.trim(),
familyCode: createForm.value.familyCode.trim() || undefined,
})
ElMessage.success('族已创建')
createVisible.value = false
createForm.value = { familyName: '', familyCode: '' }
load()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '创建失败')
} finally {
createLoading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="family-view">
<div class="family-toolbar">
<el-input
v-model="keyword"
placeholder="搜索族名称 / 编码"
clearable
style="width: 260px"
:prefix-icon="Search"
@keyup.enter="onSearch"
@clear="onSearch"
/>
<el-button type="primary" :icon="Search" @click="onSearch">搜索</el-button>
<el-button :icon="MagicStick" @click="autoGroupVisible = true">自动成族</el-button>
<el-button type="primary" plain :icon="Plus" @click="createVisible = true">新建族</el-button>
<el-button :icon="Refresh" circle @click="load" />
</div>
<el-table
v-loading="loading"
:data="items"
height="100%"
class="family-table"
@row-click="openDetail"
>
<el-table-column prop="familyCode" label="编码" width="120">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.familyCode ?? '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="familyName" label="族名称" min-width="220" show-overflow-tooltip />
<el-table-column label="成员" width="80" align="center">
<template #default="{ row }">
<el-badge
v-if="row.stale"
is-dot
type="warning"
class="family-stale-dot"
>
<span>{{ row._count.originGoods }}</span>
</el-badge>
<span v-else>{{ row._count.originGoods }}</span>
</template>
</el-table-column>
<el-table-column label="人工改价" width="90" align="center">
<template #default="{ row }">{{ row._count.priceOverrides }}</template>
</el-table-column>
<el-table-column label="托管" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.autoManaged" size="small" type="success">自动</el-tag>
<el-tag v-else size="small" type="warning">锁定</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.stale" size="small" type="danger">待处理(上游已变)</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="170">
<template #default="{ row }">{{ new Date(row.updatedAt).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }: any">
<el-button link type="primary" @click.stop="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="family-pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next, sizes"
:page-sizes="[20, 50, 100]"
@current-change="load"
@size-change="onSearch"
/>
</div>
<FamilyDetailDrawer
v-model="drawerVisible"
:family-id="currentId"
@reload="load"
/>
<AutoGroupDialog v-model="autoGroupVisible" @done="load" />
<el-dialog v-model="createVisible" title="新建产品族" width="440px">
<el-form label-width="80px">
<el-form-item label="族名称" required>
<el-input v-model="createForm.familyName" placeholder="如 180G纯棉T恤(成人款)" />
</el-form-item>
<el-form-item label="族编码">
<el-input v-model="createForm.familyCode" placeholder="如 DG001(可留空)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.family-view { height: 100%; display: flex; flex-direction: column; padding: 12px; gap: 12px; }
.family-toolbar { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.family-table { flex: 1; min-height: 0; cursor: pointer; }
.family-pagination { flex-shrink: 0; display: flex; justify-content: flex-end; }
.family-stale-dot { padding-right: 6px; }
</style>
@@ -1,196 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete } from '@element-plus/icons-vue'
import type { FamilyPriceOverrideRow, ProductFamily } from '@/types'
import { productFamiliesApi } from '@/api/product-families'
const props = defineProps<{ family: ProductFamily }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const matrix = computed(() => props.family.priceMatrix)
// (工艺 × 物流) 组合页签
const combos = computed(() => {
const m = matrix.value
if (!m) return []
const out: Array<{ craft: string; logistics: string }> = []
for (const craft of m.crafts) {
for (const logistics of m.logistics) {
if (m.rows.some((r) => r.craft === craft && r.logistics === logistics)) {
out.push({ craft, logistics })
}
}
}
return out
})
const activeCombo = ref('')
watch(
combos,
(list) => {
if (list.length && !list.some((c) => `${c.craft}|${c.logistics}` === activeCombo.value)) {
activeCombo.value = `${list[0].craft}|${list[0].logistics}`
}
},
{ immediate: true },
)
const currentCraft = computed(() => activeCombo.value.split('|')[0] ?? '')
const currentLogistics = computed(() => activeCombo.value.split('|')[1] ?? '')
// 当前组合下的矩阵:行=尺码,列=颜色
const sizeNames = computed(() => matrix.value?.sizes.map((s) => ({ key: s.key, name: s.name ?? s.key })) ?? [])
const colorKeys = computed(() => matrix.value?.colors.map((c) => ({ key: c.key, name: c.name ?? c.key })) ?? [])
function rowOf(sizeKey: string, colorKey: string) {
return matrix.value?.rows.find(
(r) => r.sizeId === sizeKey && r.colorId === colorKey && r.craft === currentCraft.value && r.logistics === currentLogistics.value,
)
}
const editing = ref<Record<string, number>>({})
function startEdit(sizeKey: string, colorKey: string) {
const row = rowOf(sizeKey, colorKey)
if (!row) return
editing.value[`${sizeKey}|${colorKey}`] = Number(row.price)
}
async function saveEdit(sizeKey: string, colorKey: string) {
const key = `${sizeKey}|${colorKey}`
const value = editing.value[key]
const row = rowOf(sizeKey, colorKey)
if (row === undefined || value === undefined || Number.isNaN(value)) return
if (Math.abs(value - Number(row.price)) < 0.0001) return
try {
await productFamiliesApi.putOverrides(props.family.id, [
{ sizeId: sizeKey, colorId: colorKey, craft: currentCraft.value, logistics: currentLogistics.value, price: value },
])
ElMessage.success('已改价')
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '改价失败')
} finally {
delete editing.value[key]
}
}
async function removeOverride(o: FamilyPriceOverrideRow) {
try {
await productFamiliesApi.deleteOverrides(props.family.id, [
{ sizeId: o.sizeId, colorId: o.colorId, craft: o.craft, logistics: o.logistics },
])
ElMessage.success('已恢复推导价')
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message ?? '删除失败')
}
}
</script>
<template>
<div class="pop">
<el-tabs v-model="activeCombo" type="card">
<el-tab-pane
v-for="c in combos"
:key="`${c.craft}|${c.logistics}`"
:name="`${c.craft}|${c.logistics}`"
:label="`${c.craft} · ${c.logistics}`"
/>
</el-tabs>
<div class="pop-matrix-wrap">
<table class="pop-matrix">
<thead>
<tr>
<th class="pop-corner">尺码 \ 颜色</th>
<th v-for="c in colorKeys" :key="c.key">{{ c.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="s in sizeNames" :key="s.key">
<th class="pop-size">{{ s.name }}</th>
<td v-for="c in colorKeys" :key="c.key" :class="{ 'pop-na': !rowOf(s.key, c.key) }">
<template v-if="rowOf(s.key, c.key)">
<el-input-number
v-if="editing[`${s.key}|${c.key}`] !== undefined"
:model-value="editing[`${s.key}|${c.key}`]"
:min="0.01"
:precision="2"
:controls="false"
size="small"
autofocus
@update:model-value="(v: number | undefined) => (editing[`${s.key}|${c.key}`] = v ?? 0)"
@blur="saveEdit(s.key, c.key)"
@keyup.enter="saveEdit(s.key, c.key)"
/>
<span
v-else
class="pop-price"
:class="{ 'pop-manual': rowOf(s.key, c.key)!.manual }"
:title="rowOf(s.key, c.key)!.manual ? '人工改价' : '推导价(点击改价)'"
@click="startEdit(s.key, c.key)"
>
¥{{ rowOf(s.key, c.key)!.price }}
<el-tag v-if="rowOf(s.key, c.key)!.manual" size="small" type="warning">改</el-tag>
</span>
</template>
<span v-else class="pop-na-text">—</span>
</td>
</tr>
</tbody>
</table>
</div>
<template v-if="family.priceOverrides.length">
<h4 class="pop-sub">已设人工价({{ family.priceOverrides.length }}</h4>
<el-table :data="family.priceOverrides" size="small" border max-height="220">
<el-table-column label="尺码" width="90">
<template #default="{ row }">
{{ matrix?.sizes.find((s) => s.key === row.sizeId)?.name ?? row.sizeId }}
</template>
</el-table-column>
<el-table-column label="颜色" width="110">
<template #default="{ row }">
{{ matrix?.colors.find((c) => c.key === row.colorId)?.name ?? row.colorId }}
</template>
</el-table-column>
<el-table-column prop="craft" label="工艺" width="100" />
<el-table-column prop="logistics" label="物流" width="110" />
<el-table-column prop="price" label="人工价" width="90" align="right" />
<el-table-column label="推导价" width="90" align="right">
<template #default="{ row }">{{ row.derivedPrice ?? '—' }}</template>
</el-table-column>
<el-table-column label="差额" width="90" align="right">
<template #default="{ row }">
<span v-if="row.diff" :class="Number(row.diff) > 0 ? 'pop-up' : 'pop-down'">{{ row.diff }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="70" fixed="right">
<template #default="{ row }: any">
<el-button link type="danger" :icon="Delete" @click="removeOverride(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
</template>
<style scoped>
.pop { display: flex; flex-direction: column; gap: 10px; }
.pop-matrix-wrap { overflow: auto; max-height: 360px; border: 1px solid var(--el-border-color-lighter); }
.pop-matrix { border-collapse: collapse; font-size: 12px; }
.pop-matrix th, .pop-matrix td { border: 1px solid var(--el-border-color-lighter); padding: 4px 10px; text-align: center; min-width: 72px; }
.pop-matrix thead th, .pop-size { background: var(--el-fill-color-light); font-weight: 600; }
.pop-corner { position: sticky; left: 0; z-index: 2; background: var(--el-fill-color-light); }
.pop-size { position: sticky; left: 0; z-index: 1; }
.pop-price { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
.pop-manual { color: var(--el-color-warning); font-weight: 600; }
.pop-na { background: var(--el-fill-color-blank); }
.pop-na-text { color: var(--el-text-color-placeholder); }
.pop-up { color: var(--el-color-danger); }
.pop-down { color: var(--el-color-success); }
.pop-sub { margin: 4px 0 0; }
</style>
@@ -1,17 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { markRaw, ref, watch } from 'vue' import { markRaw, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Files, Goods, Refresh } from '@element-plus/icons-vue' import { Goods, Refresh } from '@element-plus/icons-vue'
import GoodsView from '@/views/goods/GoodsView.vue' import GoodsView from '@/views/goods/GoodsView.vue'
import SyncView from '@/views/sync/SyncView.vue' import SyncView from '@/views/sync/SyncView.vue'
import FamilyView from '@/views/product-family/FamilyView.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tabs = [ const tabs = [
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) }, { name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
{ name: 'families', label: '产品族', icon: markRaw(Files), comp: markRaw(FamilyView) },
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) }, { name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
] ]
+7 -10
View File
@@ -126,17 +126,14 @@ pnpm --filter @inkreach/api backfill:product-families
**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON, **边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON,
且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。 且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。
**后台操作入口(二期已上线)**:商品中心新增「产品族」标签页(`/goods?tab=families` **后台操作入口(族替代旧主源/副源,界面保持原有布局)**
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
- 列表:关键词/分页、成员数、人工改价数、自动托管/锁定状态、`stale` 待处理标记 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动建族)
- 「自动成族」:先预览候选分组(SDS 分类聚合),确认后应用并逐族重算; - 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」:
- 详情抽屉:canonical 字段编辑(名称/编码/主图/主链接)、`autoManaged` 开关、手动重算、 显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员——
成员增删(远程搜索无族链接)、族内新建自定义成员 操作直接作用于族(并集与价格矩阵随重算更新)
- 价格矩阵:按 (工艺 × 物流) 切换页签,行=尺码、列=颜色;点击任意格行内改价 - 人工改价/自动成族等族管理 API`/product-families/*`)保留,供脚本或后续界面使用。
(人工格高亮并带「改」标),下方覆盖列表展示推导价对照与差额,删除即恢复推导价;
- 商品配置页(GoodsView):右侧原产品树节点带族编码徽标与 `族待处理` 提示;
配置商品时兄弟链接的自动勾选改为**按族匹配**(familyId 相同,无族时回退名称 3 段规则)。
+1 -7
View File
@@ -162,13 +162,7 @@ apps/admin/
│ ├── types/index.ts # 共享类型 │ ├── types/index.ts # 共享类型
│ ├── views/ │ ├── views/
│ │ ├── login/LoginView.vue # 登录 │ │ ├── login/LoginView.vue # 登录
│ │ ├── goods/GoodsView.vue │ │ ├── goods/GoodsView.vue # 商品配置(编辑弹窗内管理族成员:设为主链接/移除出族/搜索添加)
│ │ ├── product-family/ # 产品族管理(商品中心第 2 个标签页)
│ │ │ ├── FamilyView.vue # 列表 + 新建 + 入口
│ │ │ ├── FamilyDetailDrawer.vue # 详情抽屉:canonical/成员/自定义成员/重算/矩阵摘要
│ │ │ ├── AutoGroupDialog.vue # 自动成族预览与应用
│ │ │ ├── PriceOverridePanel.vue # 工艺×物流页签的改价矩阵 + 覆盖列表
│ │ │ └── CustomMemberDialog.vue # 族内创建自定义成员
│ │ ├── categories/CategoriesView.vue │ │ ├── categories/CategoriesView.vue
│ │ ├── countries/CountriesView.vue │ │ ├── countries/CountriesView.vue
│ │ ├── tags/TagsView.vue │ │ ├── tags/TagsView.vue