feat(goods): per-member detail tabs in edit dialog; revert right-tree to raw names; split configured/family badges; add GET /origin-goods/:id
This commit is contained in:
@@ -10,6 +10,11 @@ export const originGoodsApi = {
|
|||||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 单链接详情(detail + variants,成员展开面板用)
|
||||||
|
getOriginGood: (id: string) => {
|
||||||
|
return request.get<any, Record<string, any>>(`/origin-goods/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
getTags: (id: string) => {
|
getTags: (id: string) => {
|
||||||
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
|
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -286,12 +286,12 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const roots = tree.tree.map(mapCat)
|
const roots = tree.tree.map(mapCat)
|
||||||
// 已配置数按族计入:链接成族(或已配置商品)即视为已配置,后序汇总到各级分类
|
// 已配置数 = 已配置为官网商品的链接(成族但未配置不算)
|
||||||
function fillConfiguredEff(nodes: any[]): number {
|
function fillConfiguredEff(nodes: any[]): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.isOG) {
|
if (n.isOG) {
|
||||||
n.configuredEff = n.configuredCount > 0 || n.familyId ? 1 : 0
|
n.configuredEff = n.configuredCount > 0 ? 1 : 0
|
||||||
} else {
|
} else {
|
||||||
n.configuredEff = fillConfiguredEff(n.children ?? [])
|
n.configuredEff = fillConfiguredEff(n.children ?? [])
|
||||||
}
|
}
|
||||||
@@ -305,12 +305,8 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
|
|
||||||
function rightFilterNode(_value: string, data: any) {
|
function rightFilterNode(_value: string, data: any) {
|
||||||
if (data.isOG) {
|
if (data.isOG) {
|
||||||
// 「仅未配置」同样把族内链接视为已配置
|
if (showUnconfiguredOnly.value && data.configuredCount > 0) return false
|
||||||
if (showUnconfiguredOnly.value && (data.configuredCount > 0 || data.familyId)) return false
|
if (searchKeyword.value && !data.label.includes(searchKeyword.value)) return false
|
||||||
if (searchKeyword.value) {
|
|
||||||
const kw = searchKeyword.value
|
|
||||||
if (!data.label.includes(kw) && !cleanLinkName(data.label).includes(kw)) return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -519,13 +515,14 @@ function locateInRightTree(originGoodId: string) {
|
|||||||
const locateFlashGoodId = ref<string>('')
|
const locateFlashGoodId = ref<string>('')
|
||||||
|
|
||||||
function locateInLeftTree(originGoodId: string) {
|
function locateInLeftTree(originGoodId: string) {
|
||||||
// 链接 → 商品:优先精确匹配该链接配置的商品,其次同族商品(族内任一链接都可定位)
|
// 链接 → 商品:主链接精确匹配 → 同族商品 → 旧副源合并商品
|
||||||
const ogNode = findOgNodeById(originGoodId)
|
const ogNode = findOgNodeById(originGoodId)
|
||||||
const familyId = ogNode?.familyId ?? null
|
const familyId = ogNode?.familyId ?? null
|
||||||
const good = allGoods.value.find(g => String(g.originGoodId) === String(originGoodId))
|
const good = allGoods.value.find(g => String(g.originGoodId) === String(originGoodId))
|
||||||
?? (familyId
|
?? (familyId
|
||||||
? allGoods.value.find(g => g.originGood?.family?.familyId === familyId)
|
? allGoods.value.find(g => g.originGood?.family?.familyId === familyId)
|
||||||
: null)
|
: null)
|
||||||
|
?? allGoods.value.find(g => (g.mergedOriginGoods ?? []).some(m => String(m.id) === String(originGoodId)))
|
||||||
if (!good) {
|
if (!good) {
|
||||||
ElMessage.warning('该原产品尚未配置到官网')
|
ElMessage.warning('该原产品尚未配置到官网')
|
||||||
return
|
return
|
||||||
@@ -1049,12 +1046,17 @@ onMounted(() => loadAll())
|
|||||||
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
|
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
|
||||||
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
|
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
|
||||||
<div v-else class="og-thumb og-thumb-placeholder" />
|
<div v-else class="og-thumb og-thumb-placeholder" />
|
||||||
<span class="og-name" :title="data.label">{{ cleanLinkName(data.label) }}</span>
|
<span class="og-name" :title="data.goodName">{{ data.label }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="data.familyId || data.configuredCount > 0"
|
v-if="data.configuredCount > 0"
|
||||||
class="og-badge og-badge--ok"
|
class="og-badge og-badge--ok"
|
||||||
:title="data.familyId ? `族 ${data.familyCode || ''} · ${data.familyName || ''}` : `已配置 ${data.configuredCount} 国`"
|
:title="`已配置 ${data.configuredCount} 国`"
|
||||||
>已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }}</span>
|
>已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="data.familyId"
|
||||||
|
class="og-badge og-badge--family"
|
||||||
|
:title="`族 ${data.familyCode || ''}:已归族但尚未配置为官网商品`"
|
||||||
|
>成族</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="og-badge og-badge--warn"
|
class="og-badge og-badge--warn"
|
||||||
@@ -1076,12 +1078,12 @@ onMounted(() => loadAll())
|
|||||||
@click.stop="handleSyncOriginDetail(data)"
|
@click.stop="handleSyncOriginDetail(data)"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="data.configuredCount > 0 || data.familyId"
|
v-if="data.configuredCount > 0"
|
||||||
size="small" link :icon="Aim" title="定位到官网商品"
|
size="small" link :icon="Aim" title="定位到官网商品"
|
||||||
@click.stop="locateInLeftTree(data.rawId)"
|
@click.stop="locateInLeftTree(data.rawId)"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!data.configuredCount && !data.familyId"
|
v-if="!data.configuredCount"
|
||||||
size="small" type="primary" link class="og-config-btn"
|
size="small" type="primary" link class="og-config-btn"
|
||||||
@click.stop="openConfigFromRightTree(data)"
|
@click.stop="openConfigFromRightTree(data)"
|
||||||
>配置</el-button>
|
>配置</el-button>
|
||||||
@@ -1453,6 +1455,9 @@ onMounted(() => loadAll())
|
|||||||
.og-badge--ok {
|
.og-badge--ok {
|
||||||
color: #67c23a; background: #f0f9eb;
|
color: #67c23a; background: #f0f9eb;
|
||||||
}
|
}
|
||||||
|
.og-badge--family {
|
||||||
|
color: #337ecc; background: #ecf5ff;
|
||||||
|
}
|
||||||
.og-badge--warn {
|
.og-badge--warn {
|
||||||
color: #ff6800; background: #fff2e8;
|
color: #ff6800; background: #fff2e8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,30 +31,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const editLoading = ref(false)
|
const editLoading = ref(false)
|
||||||
const editDetailLoading = ref(false)
|
const editDetailLoading = ref(false)
|
||||||
const detailSyncing = ref(false)
|
/** 正在同步详情的成员行 id */
|
||||||
|
const detailSyncing = ref<string>('')
|
||||||
const editGood = ref<Good | GoodDetail | null>(null)
|
const editGood = ref<Good | GoodDetail | null>(null)
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||||
categoryId: '', positionId: '',
|
categoryId: '', positionId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
|
|
||||||
const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? [])
|
|
||||||
const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? [])
|
|
||||||
const editSizeRows = computed(() => {
|
|
||||||
const rows = editOriginDetail.value?.sizeChart?.rows ?? []
|
|
||||||
return rows.map((row: any) => ({
|
|
||||||
...row,
|
|
||||||
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
|
|
||||||
out[item.key] = item.cm ?? '-'
|
|
||||||
return out
|
|
||||||
}, {}),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
|
|
||||||
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
|
||||||
/** 商品自身链接的定价维度(SKU 表列) */
|
|
||||||
const editLinkDims = computed(() => linkDims(editGood.value?.originGood?.goodName ?? null))
|
|
||||||
const categoryCascader = computed(() => buildCascader(props.categories))
|
const categoryCascader = computed(() => buildCascader(props.categories))
|
||||||
|
|
||||||
// ─── 自定义商品内容编辑 ───
|
// ─── 自定义商品内容编辑 ───
|
||||||
@@ -176,10 +161,70 @@ function toggleMemberExpand(key: string) {
|
|||||||
else {
|
else {
|
||||||
next.add(key)
|
next.add(key)
|
||||||
loadMemberPrices(key)
|
loadMemberPrices(key)
|
||||||
|
ensureMemberDetail(key)
|
||||||
}
|
}
|
||||||
expandedMemberKeys.value = next
|
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 ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberDims(id: string) {
|
||||||
|
const row = editFamilyMembers.value.find((m) => m.id === id)
|
||||||
|
return linkDims(row?.goodName ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '' }
|
||||||
|
}
|
||||||
|
|
||||||
/** 该链接在族价格矩阵中的格子(craft/logistics 即该链接的标签维度) */
|
/** 该链接在族价格矩阵中的格子(craft/logistics 即该链接的标签维度) */
|
||||||
function loadMemberPrices(ogId: string) {
|
function loadMemberPrices(ogId: string) {
|
||||||
const row = editFamilyMembers.value.find((m) => m.id === ogId)
|
const row = editFamilyMembers.value.find((m) => m.id === ogId)
|
||||||
@@ -406,22 +451,6 @@ function onEditCascaderChange(val: any) {
|
|||||||
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncOneDetail() {
|
|
||||||
if (editIsCustom.value) return
|
|
||||||
const sdsGoodId = editGood.value?.originGood?.sdsGoodId
|
|
||||||
if (!sdsGoodId) return
|
|
||||||
detailSyncing.value = true
|
|
||||||
try {
|
|
||||||
const result = await syncApi.syncOneProductDetail(sdsGoodId)
|
|
||||||
const detail = await goodsApi.getGoodById(editGood.value!.id)
|
|
||||||
applyGood(detail)
|
|
||||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
|
||||||
emit('changed')
|
|
||||||
} catch (error: any) {
|
|
||||||
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
|
|
||||||
} finally { detailSyncing.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditSubmit() {
|
async function handleEditSubmit() {
|
||||||
let customPayload: any = null
|
let customPayload: any = null
|
||||||
if (editIsCustom.value) {
|
if (editIsCustom.value) {
|
||||||
@@ -578,13 +607,6 @@ async function handleDeleteGood() {
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-button size="small" type="primary" :loading="memberTagSaving === row.id" @click="saveMemberTags(row)">保存标签</el-button>
|
<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>
|
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.id" @click="resetMemberTags(row)">恢复自动</el-button>
|
||||||
<el-button
|
|
||||||
v-if="row.id === editGood?.originGoodId"
|
|
||||||
size="small"
|
|
||||||
:icon="Refresh"
|
|
||||||
:loading="detailSyncing"
|
|
||||||
@click="handleSyncOneDetail"
|
|
||||||
>同步详情</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
|
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
|
||||||
<div class="member-price-head">
|
<div class="member-price-head">
|
||||||
@@ -612,6 +634,71 @@ async function handleDeleteGood() {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="row.source === 'SDS'" class="member-extra">
|
||||||
|
<div v-if="!memberDetail(row.id) || memberDetail(row.id).loading" class="md-empty">详情加载中…</div>
|
||||||
|
<div v-else-if="!memberDetail(row.id).hasDetail" class="md-empty">
|
||||||
|
尚未同步详情
|
||||||
|
<el-button size="small" :icon="Refresh" :loading="detailSyncing === row.id" @click="handleSyncMemberDetail(row)">同步详情</el-button>
|
||||||
|
</div>
|
||||||
|
<el-tabs v-else 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中…</div>
|
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中…</div>
|
||||||
@@ -644,9 +731,10 @@ async function handleDeleteGood() {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
<!-- 自定义商品内容编辑(SDS 商品的详情在「关联原产品」成员面板内查看) -->
|
||||||
|
<el-tabs v-if="editIsCustom" class="detail-tabs">
|
||||||
<el-tab-pane label="商品详情">
|
<el-tab-pane label="商品详情">
|
||||||
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
|
<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.productCode" /></el-form-item>
|
||||||
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></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-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
|
||||||
@@ -667,43 +755,14 @@ async function handleDeleteGood() {
|
|||||||
<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.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-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-descriptions v-else :column="2" border size="small">
|
|
||||||
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="净重">{{ editOriginDetail.minWeightG ?? '-' }} g</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="生产工艺">{{ editOriginDetail.productionProcess || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="材质">{{ editOriginDetail.materialDescription || '-' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
|
<el-tab-pane label="尺码表">
|
||||||
<el-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
|
<el-input v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
|
||||||
<el-table v-else :data="editSizeRows" border max-height="320">
|
|
||||||
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
|
|
||||||
<el-table-column
|
|
||||||
v-for="column in editSizeColumns"
|
|
||||||
:key="column.key"
|
|
||||||
:prop="column.key"
|
|
||||||
:label="`${column.name} (cm)`"
|
|
||||||
min-width="120"
|
|
||||||
/>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
|
<el-tab-pane label="包装规格">
|
||||||
<el-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
|
<el-input v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
|
||||||
<el-table v-else :data="editPackageRows" border max-height="320">
|
|
||||||
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
|
|
||||||
<el-table-column label="包装尺寸 (cm)" min-width="160">
|
|
||||||
<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="120" />
|
|
||||||
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="150" />
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane :label="`SKU (${editVariants.length})`">
|
<el-tab-pane :label="`SKU (${customContentForm.variants.length})`">
|
||||||
<template v-if="editIsCustom">
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
|
<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 :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="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
|
||||||
@@ -723,30 +782,8 @@ async function handleDeleteGood() {
|
|||||||
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></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-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-table>
|
||||||
</template>
|
|
||||||
<el-table v-else :data="editVariants" border max-height="320">
|
|
||||||
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
|
||||||
<el-table-column label="物流" width="110">
|
|
||||||
<template #default>{{ editLinkDims.logistics || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="工艺" width="100">
|
|
||||||
<template #default>{{ editLinkDims.crafts.join(' / ') || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="印花数量" width="100">
|
|
||||||
<template #default>{{ editLinkDims.printCount || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="sizeName" label="尺码" width="90" />
|
|
||||||
<el-table-column prop="colorName" label="颜色" width="100" />
|
|
||||||
<el-table-column prop="price" label="价格" width="100" />
|
|
||||||
<el-table-column label="状态" width="90">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
|
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
|
||||||
<el-button @click="emit('update:visible', false)">取消</el-button>
|
<el-button @click="emit('update:visible', false)">取消</el-button>
|
||||||
@@ -800,6 +837,12 @@ async function handleDeleteGood() {
|
|||||||
.member-tags-edit .md-label { flex-shrink: 0; }
|
.member-tags-edit .md-label { flex-shrink: 0; }
|
||||||
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
|
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
|
||||||
.member-price-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
.member-price-head { display: flex; align-items: center; justify-content: space-between; 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-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
|
||||||
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
|
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
|
||||||
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
||||||
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
|
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export class OriginGoodsController {
|
|||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: '单链接详情(detail + variants,成员展开面板用)' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/tags')
|
@Get(':id/tags')
|
||||||
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
|
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
|
||||||
getTags(@Param('id') id: string) {
|
getTags(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ describe('OriginGoodsService', () => {
|
|||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('findOne returns og with detail/variants payload', async () => {
|
||||||
|
const og = await prisma.originGood.findFirstOrThrow({
|
||||||
|
where: { sdsGoodId: `sds-${stamp}-3` },
|
||||||
|
});
|
||||||
|
const result = await service.findOne(og.id);
|
||||||
|
expect(result.id).toBe(og.id.toString());
|
||||||
|
expect(result.sdsGoodId).toBe(`sds-${stamp}-3`);
|
||||||
|
expect(result.variantCount).toBe(0);
|
||||||
|
expect(result.hasDetail).toBe(false);
|
||||||
|
expect(result.detail).toBeNull();
|
||||||
|
expect(result.variants).toEqual([]);
|
||||||
|
await expect(service.findOne(og.id + 10_000_000n)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns paginated results', async () => {
|
it('returns paginated results', async () => {
|
||||||
const page1 = await service.findAll({
|
const page1 = await service.findAll({
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
@@ -175,6 +175,54 @@ export class OriginGoodsService {
|
|||||||
return this.getTags(id);
|
return this.getTags(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单链接详情(含 detail 与 variants,成员展开面板用) */
|
||||||
|
async findOne(id: bigint) {
|
||||||
|
const og = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
_count: { select: { variants: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||||
|
const detail = og.detail;
|
||||||
|
return {
|
||||||
|
id: og.id.toString(),
|
||||||
|
sdsGoodId: og.sdsGoodId,
|
||||||
|
goodName: og.goodName,
|
||||||
|
goodImage: og.goodImage,
|
||||||
|
goodPrice: og.goodPrice === null || og.goodPrice === undefined ? null : og.goodPrice.toString(),
|
||||||
|
source: og.source,
|
||||||
|
delisted: og.delisted,
|
||||||
|
hasDetail: Boolean(detail),
|
||||||
|
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||||
|
variantCount: og._count.variants,
|
||||||
|
detail: detail
|
||||||
|
? {
|
||||||
|
productCode: detail.productCode,
|
||||||
|
englishName: detail.englishName,
|
||||||
|
productionCycleHours: detail.productionCycleHours,
|
||||||
|
minWeightG: detail.minWeightG === null ? null : detail.minWeightG.toString(),
|
||||||
|
productionProcess: detail.productionProcess,
|
||||||
|
materialDescription: detail.materialDescription,
|
||||||
|
sizeChart: (detail.sizeChart ?? null) as unknown,
|
||||||
|
packageSpecs: (detail.packageSpecs ?? null) as unknown,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
variants: og.variants.map((v) => ({
|
||||||
|
sdsVariantId: v.sdsVariantId,
|
||||||
|
sku: v.sku,
|
||||||
|
sizeId: v.sizeId,
|
||||||
|
sizeName: v.sizeName,
|
||||||
|
colorId: v.colorId,
|
||||||
|
colorName: v.colorName,
|
||||||
|
price: v.price === null ? null : v.price.toString(),
|
||||||
|
enabled: v.enabled,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||||
const { page, pageSize, keyword } = query;
|
const { page, pageSize, keyword } = query;
|
||||||
const where: Prisma.OriginGoodWhereInput = {
|
const where: Prisma.OriginGoodWhereInput = {
|
||||||
|
|||||||
@@ -131,17 +131,26 @@ pnpm --filter @inkreach/api backfill:product-families
|
|||||||
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
|
||||||
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);族内**不区分主次**
|
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);族内**不区分主次**
|
||||||
(成员列表无设为主链接按钮,主链接仅作为详情数据源的内部实现);
|
(成员列表无设为主链接按钮,主链接仅作为详情数据源的内部实现);
|
||||||
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,成员行(原始链接名,便于核对)
|
- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,成员行显示**原始链接名**(便于核对)
|
||||||
可**逐个展开**——查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库)、
|
并可**逐个展开**——查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库)、
|
||||||
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生)、
|
**配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生)、
|
||||||
**改价格**(尺码 × 颜色 → 价格表格,写入族价格覆盖,人工格子可「还原」回推导价);
|
**改价格**(尺码 × 颜色 → 价格表格,写入族价格覆盖,人工格子可「还原」回推导价)、
|
||||||
成员行支持 `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
|
**查看成员自己的详情**(商品详情 / 尺码表 / 包装规格 / SKU 四个页签,懒加载
|
||||||
- 编辑弹窗 SKU 表含 **物流 / 工艺 / 印花数量** 维度列(价格由
|
`GET /origin-goods/:id`,SKU 表带 物流/工艺/印花数量 维度列;未同步时可在面板内直接同步);
|
||||||
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定);
|
SDS 商品不再在弹窗底部展示单一链接的旧详情区(自定义商品的编辑表单仍保留在底部);
|
||||||
|
- 编辑弹窗商品「名称」预填解析后的「品名 型号」,保存即采用解析名;右侧原产品库与配置弹窗
|
||||||
|
始终显示**原始链接名**;
|
||||||
|
- 右树徽标语义:**已配置 N**(绿,已配置为官网商品)/ **成族**(蓝,已归族但尚未配置)/
|
||||||
|
**未配置**(橙);「定位到官网商品」仅在已配置的链接上出现(按 主链接 → 同族 → 旧副源
|
||||||
|
顺序定位并高亮),「配置」在未配置的链接上出现;
|
||||||
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用;
|
- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用;
|
||||||
- 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、
|
- 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、
|
||||||
GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。
|
GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。
|
||||||
|
|
||||||
|
> 注意:同一族的多条链接(如 单面印花/双面印花)如果各自都点了一次「配置」,会产生多个
|
||||||
|
> 解析后同名的官网商品(名称相同但关联链接不同)——族合并的预期用法是每族配置一个商品,
|
||||||
|
> 其余链接保持为族成员即可;重复的可手动删除。
|
||||||
|
|
||||||
**链接级标签(自动派生 + 人工修正,2026-08 规则改版)**:
|
**链接级标签(自动派生 + 人工修正,2026-08 规则改版)**:
|
||||||
|
|
||||||
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),
|
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),
|
||||||
|
|||||||
Reference in New Issue
Block a user