feat(admin): product family management tab
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user