feat(admin): product family management tab

This commit is contained in:
yeuimu
2026-08-28 13:28:37 +08:00
parent dcd2b235b9
commit 07f3134f79
7 changed files with 954 additions and 1 deletions
@@ -0,0 +1,326 @@
<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>