revert(admin): restore original layout, replace primary/secondary source UI with family members
This commit is contained in:
Vendored
-7
@@ -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']
|
||||||
|
|||||||
@@ -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) },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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 段规则)。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user