revert(admin): keep original admin layout with family replacing primary/secondary sources
This commit is contained in:
Vendored
-7
@@ -13,22 +13,17 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
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']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
@@ -46,12 +41,10 @@ declare module 'vue' {
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
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 SyncView from '@/views/sync/SyncView.vue'
|
||||
import FamilyView from '@/views/product-family/FamilyView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ 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) },
|
||||
]
|
||||
|
||||
|
||||
@@ -126,17 +126,14 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON,
|
||||
且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。
|
||||
|
||||
**后台操作入口(二期已上线)**:商品中心新增「产品族」标签页(`/goods?tab=families`):
|
||||
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
|
||||
|
||||
|
||||
- 列表:关键词/分页、成员数、人工改价数、自动托管/锁定状态、`stale` 待处理标记;
|
||||
- 「自动成族」:先预览候选分组(SDS 分类聚合),确认后应用并逐族重算;
|
||||
- 详情抽屉:canonical 字段编辑(名称/编码/主图/主链接)、`autoManaged` 开关、手动重算、
|
||||
成员增删(远程搜索无族链接)、族内新建自定义成员;
|
||||
- 价格矩阵:按 (工艺 × 物流) 切换页签,行=尺码、列=颜色;点击任意格行内改价
|
||||
(人工格高亮并带「改」标),下方覆盖列表展示推导价对照与差额,删除即恢复推导价;
|
||||
- 商品配置页(GoodsView):右侧原产品树节点带族编码徽标与 `族待处理` 提示;
|
||||
配置商品时兄弟链接的自动勾选改为**按族匹配**(familyId 相同,无族时回退名称 3 段规则)。
|
||||
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动建族);
|
||||
- 编辑弹窗的原「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」:
|
||||
显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员——
|
||||
操作直接作用于族(并集与价格矩阵随重算更新);
|
||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -162,13 +162,7 @@ apps/admin/
|
||||
│ ├── types/index.ts # 共享类型
|
||||
│ ├── views/
|
||||
│ │ ├── login/LoginView.vue # 登录
|
||||
│ │ ├── goods/GoodsView.vue
|
||||
│ │ ├── product-family/ # 产品族管理(商品中心第 2 个标签页)
|
||||
│ │ │ ├── FamilyView.vue # 列表 + 新建 + 入口
|
||||
│ │ │ ├── FamilyDetailDrawer.vue # 详情抽屉:canonical/成员/自定义成员/重算/矩阵摘要
|
||||
│ │ │ ├── AutoGroupDialog.vue # 自动成族预览与应用
|
||||
│ │ │ ├── PriceOverridePanel.vue # 工艺×物流页签的改价矩阵 + 覆盖列表
|
||||
│ │ │ └── CustomMemberDialog.vue # 族内创建自定义成员
|
||||
│ │ ├── goods/GoodsView.vue # 商品配置(编辑弹窗内管理族成员:设为主链接/移除出族/搜索添加)
|
||||
│ │ ├── categories/CategoriesView.vue
|
||||
│ │ ├── countries/CountriesView.vue
|
||||
│ │ ├── tags/TagsView.vue
|
||||
|
||||
Reference in New Issue
Block a user