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
+7
View File
@@ -13,17 +13,22 @@ 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']
@@ -41,10 +46,12 @@ 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']
@@ -0,0 +1,91 @@
<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>
@@ -0,0 +1,143 @@
<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>
@@ -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>
@@ -0,0 +1,188 @@
<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>
@@ -0,0 +1,196 @@
<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,15 +1,17 @@
<script setup lang="ts">
import { markRaw, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Goods, Refresh } from '@element-plus/icons-vue'
import { Files, 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) },
]