Files
inkreach-official-website/apps/admin/src/views/goods/components/GoodsEditDialog.vue
T
yeuimu 1e07c62fc8 feat(admin): country drag-sort + always-available member detail sync
- Country 加 sort_order(默认 0 保持 id 序);PATCH /countries/sort 批量保存
  顺序(对齐 /tags/sort 模式);findAll 与公开 /public/countries 均按
  sortOrder 排序
- CountriesView 表格改为可拖拽行列表:拖动松开即全量保存新顺序,失败回滚
- 商品编辑弹窗成员展开面板:同步详情按钮常驻(已同步显示 重新同步详情),
  不再只在未同步态出现
- goods.service.spec 的 FamilyRecomputeService mock 补齐 syncFamilyTags 等
  方法(全量并行时其他套件的扫名归族会把本套件夹具收进族,create/update
  会调用到,mock 缺方法导致偶发 TypeError)
- api 164/164、admin typecheck+22/22+构建全绿
2026-08-28 20:00:56 +08:00

928 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowDown, QuestionFilled } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Good, GoodDetail, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { originGoodsApi } from '@/api/origin-goods'
import { productFamiliesApi } from '@/api/product-families'
import { countriesApi } from '@/api/countries'
import { syncApi } from '@/api/sync'
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
goodId: string
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
/** 商品数据变化(保存/同步/族变更),父级刷新左右树 */
(e: 'changed'): void
(e: 'deleted'): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const editLoading = ref(false)
const editDetailLoading = ref(false)
/** 正在同步详情的成员行 id */
const detailSyncing = ref<string>('')
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', positionId: '',
})
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
const categoryCascader = computed(() => buildCascader(props.categories))
// ─── 自定义商品内容编辑 ───
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
// ─── 关联原产品(族成员,可展开查看详情 / 配标签 / 改价格) ───
interface FamilyMemberRow {
id: string
goodName: string
goodImage: string | null
source: string
delisted: boolean
sdsGoodId: string
goodPrice: string | null
variantCount: number
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }>
}
interface MemberPriceCell {
sizeId: string
colorId: string
sizeName: string | null
colorName: string | null
printCount: string
craft: string
logistics: string
price: string
manual: boolean
editPrice: string
}
const editFamilyId = ref<string>('')
const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<FamilyMemberRow[]>([])
const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
const expandedMemberKeys = ref<Set<string>>(new Set())
const memberTagEdits = ref<Record<string, string[]>>({})
const memberTagSaving = ref<string>('')
/** 成员展开面板的 尺码×颜色→价格 编辑态(key = og id */
const memberPrices = ref<Record<string, MemberPriceCell[]>>({})
const memberPriceSaving = ref<string>('')
/** 族原始详情(含 priceMatrix */
const familyDetailRaw = ref<any>(null)
// ─── 族级并集视图:尺码表/包装规格(族物化并集)+ SKU(价格矩阵 = 各链接动态合并) ───
// 尺码行把 measurements 数组摊平成 { 列key: cm }(与成员级渲染一致,否则格子全空)
const familySizeRows = computed(() =>
((familyDetailRaw.value?.sizeChart?.rows ?? []) as any[]).map((row) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
})),
)
const familySizeColumns = computed(() => familyDetailRaw.value?.sizeChart?.columns ?? [])
const familyPackageRows = computed(() => familyDetailRaw.value?.packageSpecs?.rows ?? [])
const familySkuRows = computed(() => {
const rows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
return rows.map((r) => ({ ...r, sourceCount: Array.isArray(r.sources) ? r.sources.length : 0 }))
})
function toggleMemberExpand(key: string) {
const next = new Set(expandedMemberKeys.value)
if (next.has(key)) next.delete(key)
else {
next.add(key)
loadMemberPrices(key)
ensureMemberDetail(key)
}
expandedMemberKeys.value = next
}
// ─── 成员详情(懒加载:GET /origin-goods/:id ───
const memberDetailCache = ref<Record<string, any>>({})
function memberDetail(id: string) {
return memberDetailCache.value[id]
}
async function ensureMemberDetail(ogId: string) {
const row = editFamilyMembers.value.find((m) => m.id === ogId)
if (!row || row.source !== 'SDS' || memberDetailCache.value[ogId] !== undefined) return
memberDetailCache.value[ogId] = { loading: true }
try {
memberDetailCache.value[ogId] = await originGoodsApi.getOriginGood(ogId)
} catch {
memberDetailCache.value[ogId] = null
}
}
function memberSizeColumns(id: string) {
return memberDetail(id)?.detail?.sizeChart?.columns ?? []
}
function memberSizeRows(id: string) {
const rows = memberDetail(id)?.detail?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
}
function memberPackageRows(id: string) {
return memberDetail(id)?.detail?.packageSpecs?.rows ?? []
}
function memberVariants(id: string) {
return memberDetail(id)?.variants ?? []
}
// 矩阵三个归因维度的合法取值(与 api 端 auto-tag-rules 一致)
const PRINT_COUNT_VALUES = ['单面印花', '双面印花']
const CRAFT_VALUES = ['烫画', '直喷', '不打印']
const LOGISTICS_VALUES = ['包邮', '不包邮']
/** 成员的矩阵归因维度:优先取有效标签(人工接管后仍准确),回退按名称解析 */
function memberDims(id: string) {
const row = editFamilyMembers.value.find((m) => m.id === id)
const fallback = linkDims(row?.goodName ?? null)
const tagNames = ((row as any)?.originGoodTags ?? [])
.map((r: any) => r.tag?.tagName)
.filter(Boolean) as string[]
const crafts = CRAFT_VALUES.filter((v) => tagNames.includes(v))
return {
logistics: LOGISTICS_VALUES.find((v) => tagNames.includes(v)) ?? fallback.logistics,
crafts: crafts.length ? crafts : fallback.crafts,
printCount: PRINT_COUNT_VALUES.find((v) => tagNames.includes(v)) ?? fallback.printCount,
}
}
async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) {
if (!row.sdsGoodId || detailSyncing.value) return
detailSyncing.value = row.id
try {
const result = await syncApi.syncOneProductDetail(row.sdsGoodId)
memberDetailCache.value[row.id] = await originGoodsApi.getOriginGood(row.id)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
emit('changed')
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally { detailSyncing.value = '' }
}
/** 该链接在族价格矩阵中的格子(印花数量/工艺/物流 三维即该链接的归因标签) */
function loadMemberPrices(ogId: string) {
const dims = memberDims(ogId)
if (!dims.printCount || !dims.crafts.length || !dims.logistics) {
memberPrices.value[ogId] = []
return
}
const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
memberPrices.value[ogId] = matrixRows
.filter((r: any) =>
r.printCount === dims.printCount &&
dims.crafts.includes(r.craft) &&
r.logistics === dims.logistics)
.map((r: any) => ({
sizeId: String(r.sizeId),
colorId: String(r.colorId),
sizeName: r.sizeName ?? null,
colorName: r.colorName ?? null,
printCount: r.printCount,
craft: r.craft,
logistics: r.logistics,
price: String(r.price ?? ''),
manual: Boolean(r.manual),
editPrice: String(r.price ?? ''),
}))
}
async function saveMemberPrices(row: { id: string }) {
const cells = (memberPrices.value[row.id] ?? []).filter((c) => c.editPrice !== '' && c.editPrice !== c.price)
if (!cells.length) { ElMessage.info('价格没有修改'); return }
memberPriceSaving.value = row.id
try {
await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({
sizeId: c.sizeId, colorId: c.colorId, printCount: c.printCount, craft: c.craft, logistics: c.logistics, price: c.editPrice,
})))
ElMessage.success('价格已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberPriceSaving.value = '' }
}
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? ''
editFamilyCode.value = family?.familyCode ?? null
editFamilyMembers.value = []
familyMemberCount.value = null
editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
expandedMemberKeys.value = new Set()
memberTagEdits.value = {}
memberPrices.value = {}
familyDetailRaw.value = null
if (editFamilyId.value) loadEditFamily()
}
async function loadEditFamily() {
if (!editFamilyId.value) return
try {
const f = await productFamiliesApi.detail(editFamilyId.value) as any
familyDetailRaw.value = f
editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods
const primaryOgId = String(editGood.value?.originGoodId ?? '')
const members: FamilyMemberRow[] = (f.originGoods ?? []).map((m: any) => ({
id: String(m.id),
goodName: m.goodName ?? '',
goodImage: m.goodImage ?? null,
source: m.source,
delisted: Boolean(m.delisted),
sdsGoodId: m.sdsGoodId ?? '',
goodPrice: m.goodPrice ?? null,
variantCount: m._count?.variants ?? 0,
logisticsLabel: m.logisticsLabel ?? null,
craftLabel: m.craftLabel ?? null,
warehouseLabel: m.warehouseLabel ?? null,
tagsManual: Boolean(m.tagsManual),
tags: (m.originGoodTags ?? []).map((t: any) => ({
id: String(t.tag.id),
tagName: t.tag.tagName,
tagColor: t.tag.tagColor ?? null,
manual: Boolean(t.manual),
})),
}))
// 当前商品的链接排最前,其余按 id 稳定排序(界面上不区分主次)
members.sort((a, b) => {
const aP = a.id === primaryOgId ? 0 : 1
const bP = b.id === primaryOgId ? 0 : 1
return aP - bP || a.id.localeCompare(b.id)
})
editFamilyMembers.value = members
const edits: Record<string, string[]> = {}
for (const m of members) edits[m.id] = m.tags.map((t) => t.id)
memberTagEdits.value = edits
for (const key of expandedMemberKeys.value) loadMemberPrices(key)
} finally {
editFamilyLoaded.value = true
}
}
async function saveMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
ElMessage.success('标签已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberTagSaving.value = '' }
}
async function resetMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.resetTags(row.id)
ElMessage.success('已恢复按链接名称自动解析')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '恢复失败')
} finally { memberTagSaving.value = '' }
}
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 loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '加入失败')
}
}
async function removeFamilyMember(m: { id: string }) {
if (!editFamilyId.value) return
try {
await ElMessageBox.confirm('确定把该链接移出族吗?', '确认', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
})
} catch { return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { removeOriginGoodIds: [m.id] })
ElMessage.success('已移除出族')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '移除失败')
}
}
// ─── 打开 / 保存 / 删除 / 同步 ───
watch(() => props.visible, (v) => {
if (v && props.goodId) initLoad()
})
async function initLoad() {
editGood.value = null
editDetailLoading.value = true
try {
const detail = await goodsApi.getGoodById(props.goodId)
applyGood(detail)
} catch {
ElMessage.warning('商品详情加载失败')
} finally {
editDetailLoading.value = false
}
}
function applyGood(g: GoodDetail) {
editGood.value = g
// 名称展示解析为「品名 型号」,保存后即采用解析名
editForm.value = {
id: g.id,
goodName: cleanLinkName(g.goodName) || g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
countryId: g.countryId,
cascaderCategory: findCategoryPath(props.categories, g.categoryId),
categoryId: g.categoryId,
positionId: g.positionId || '',
}
initEditFamily((g as any).originGood?.family ?? null)
if (g.originGood?.isCustom) fillCustomContent(g)
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) editForm.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleEditSubmit() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
emit('update:visible', false)
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
async function handleDeleteGood() {
const g = editGood.value
if (!g) return
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
emit('update:visible', false)
emit('deleted')
}
</script>
<template>
<el-dialog :model-value="visible" :title="cleanLinkName(editGood?.goodName) || '编辑商品'" width="900px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵。展开成员可查看详情、修正标签、调整价格。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
</div>
<div class="edit-merged-list">
<template v-for="row in editFamilyMembers" :key="row.id">
<div class="edit-merged-item">
<el-icon
class="member-expand-toggle"
:class="{ 'is-expanded': expandedMemberKeys.has(row.id) }"
@click="toggleMemberExpand(row.id)"
><ArrowDown /></el-icon>
<span class="edit-merged-name" :title="row.goodName">{{ row.goodName }}</span>
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<template v-if="row.tags.length">
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</template>
<template v-else>
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview">{{ c }}</span>
</template>
</span>
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
<div class="edit-merged-actions">
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
</div>
</div>
<div v-if="expandedMemberKeys.has(row.id)" class="member-detail-panel">
<div class="member-detail-grid">
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? ${row.goodPrice}` : '-' }}</span>
<span class="md-label">SKU </span><span class="md-val">{{ row.id === editGood?.originGoodId ? (editGood?.originGood?.variantCount ?? row.variantCount) : row.variantCount }}</span>
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
</div>
<div class="member-tags-edit">
<span class="md-label">标签</span>
<el-select
v-model="memberTagEdits[row.id]"
multiple filterable size="small"
placeholder="选择标签"
style="flex:1"
>
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button size="small" type="primary" :loading="memberTagSaving === row.id" @click="saveMemberTags(row)">保存标签</el-button>
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.id" @click="resetMemberTags(row)">恢复自动</el-button>
</div>
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
<div class="member-price-head">
<el-button size="small" type="primary" :loading="memberPriceSaving === row.id" @click="saveMemberPrices(row)">保存价格</el-button>
</div>
<el-table :data="memberPrices[row.id] ?? []" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" />
<el-table-column prop="colorName" label="颜色" min-width="120" />
<el-table-column label="价格" width="130">
<template #default="{ row: cell }">
<el-input v-model="cell.editPrice" size="small" @input="cell.editPrice = String(cell.editPrice)" />
</template>
</el-table-column>
</el-table>
</div>
<div v-if="row.source === 'SDS'" class="member-extra">
<div class="member-detail-head">
<span v-if="!memberDetail(row.id) || memberDetail(row.id).loading" class="md-empty">详情加载中</span>
<span v-else-if="!memberDetail(row.id).hasDetail" class="md-empty">尚未同步详情</span>
<el-button
size="small"
:icon="Refresh"
:loading="detailSyncing === row.id"
@click="handleSyncMemberDetail(row)"
>{{ memberDetail(row.id)?.hasDetail ? '重新同步详情' : '同步详情' }}</el-button>
</div>
<el-tabs v-if="memberDetail(row.id) && memberDetail(row.id).hasDetail" class="member-detail-tabs">
<el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ memberDetail(row.id).detail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ memberDetail(row.id).detail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ memberDetail(row.id).detail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
<el-descriptions-item label="净重">{{ memberDetail(row.id).detail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ memberDetail(row.id).detail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ memberDetail(row.id).detail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${memberSizeRows(row.id).length})`">
<el-table :data="memberSizeRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in memberSizeColumns(row.id)"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${memberPackageRows(row.id).length})`">
<el-table :data="memberPackageRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row: pkg }">
{{ pkg.dimensionsCm ? `${pkg.dimensionsCm.length}×${pkg.dimensionsCm.width}×${pkg.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${memberVariants(row.id).length})`">
<el-table :data="memberVariants(row.id)" border size="small" max-height="240">
<el-table-column prop="sku" label="SKU" min-width="160" fixed />
<el-table-column label="物流" width="100">
<template #default>{{ memberDims(row.id).logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="90">
<template #default>{{ memberDims(row.id).crafts.join(' / ') || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="95">
<template #default>{{ memberDims(row.id).printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="85" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="90" />
<el-table-column label="状态" width="80">
<template #default="{ row: variant }">
<el-tag :type="variant.enabled ? 'success' : 'info'" size="small">{{ variant.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
@click="addFamilyMember(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<!-- 自定义商品内容编辑SDS 商品的详情在关联原产品成员面板内查看 -->
<el-tabs v-if="editIsCustom" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-form label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="尺码表">
<el-input v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
</el-tab-pane>
<el-tab-pane label="包装规格">
<el-input v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
</el-tab-pane>
<el-tab-pane :label="`SKU (${customContentForm.variants.length})`">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 族级并集视图弹窗最底部):尺码表/包装规格为族并集SKU 为价格矩阵动态合并 -->
<el-tabs v-if="!editIsCustom && editFamilyId && editFamilyLoaded" class="detail-tabs">
<el-tab-pane :label="`尺码表 (${familySizeRows.length})`">
<el-table :data="familySizeRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in familySizeColumns"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${familyPackageRows.length})`">
<el-table :data="familyPackageRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${familySkuRows.length})`">
<el-table :data="familySkuRows" border size="small" max-height="260">
<el-table-column label="物流" width="110">
<template #default="{ row }">{{ row.logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="100">
<template #default="{ row }">{{ row.craft || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="100">
<template #default="{ row }">{{ row.printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" min-width="110" />
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ row.price }}</template>
</el-table-column>
<el-table-column label="链接数" width="80">
<template #default="{ row }">{{ row.sourceCount || 1 }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.manual ? 'warning' : 'success'" size="small">{{ row.manual ? '人工价' : '推导价' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-merged-box { margin-bottom: 4px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-weight: 600; font-size: 13px; margin-bottom: 8px;
}
.edit-family-code { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--el-color-primary); }
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.edit-merged-actions { flex-shrink: 0; display: flex; align-items: center; }
.edit-merged-actions .el-button + .el-button { margin-left: 4px; }
.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; }
.member-chip {
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
}
.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); }
.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; }
.member-manual-flag {
font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px;
background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0;
}
.member-expand-toggle { cursor: pointer; flex-shrink: 0; color: #909399; transition: transform 0.18s; }
.member-expand-toggle.is-expanded { transform: rotate(180deg); }
.member-detail-panel {
background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px;
padding: 10px 12px; margin: -2px 0 2px 30px;
}
.member-detail-grid {
display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 4px 10px; font-size: 12px; margin-bottom: 8px;
}
.md-label { color: #909399; font-size: 12px; }
.md-val { color: #606266; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-tags-edit { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.member-tags-edit .md-label { flex-shrink: 0; }
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.member-price-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 6px; }
.member-extra { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.md-empty {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--el-text-color-placeholder); padding: 4px 0;
}
.member-detail-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin-bottom: 4px;
}
.member-detail-head .md-empty { padding: 0; }
.member-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
.family-detail-tabs { margin-top: 10px; }
.family-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
</style>