feat(admin): manage and sync product details
This commit is contained in:
@@ -3,11 +3,11 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useVirtualList } from '@vueuse/core'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus, Edit, Delete, Search, Top,
|
||||
Plus, Edit, Delete, Search, Top, Refresh,
|
||||
FolderAdd, Aim, ArrowDown,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type {
|
||||
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
||||
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
|
||||
OriginGoodsTreeResponse,
|
||||
} from '@/types'
|
||||
import { goodsApi } from '@/api/goods'
|
||||
@@ -17,6 +17,7 @@ import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { originGoodsApi } from '@/api/origin-goods'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||
const loading = ref(false)
|
||||
@@ -53,6 +54,7 @@ const leftPct = ref(55)
|
||||
const showAllLeft = ref(false)
|
||||
const showAllRight = ref(false)
|
||||
const showUnconfiguredOnly = ref(false)
|
||||
const syncingOriginGoodIds = ref(new Set<string>())
|
||||
|
||||
function onSplitterMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
@@ -264,6 +266,11 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
configuredCount: og.configuredCount ?? 0,
|
||||
configuredCountries: og.configuredCountries ?? [],
|
||||
hasDetail: Boolean(og.hasDetail),
|
||||
detailSyncedAt: og.detailSyncedAt ?? null,
|
||||
variantCount: og.variantCount ?? 0,
|
||||
sizeRowCount: og.sizeRowCount ?? 0,
|
||||
packageRowCount: og.packageRowCount ?? 0,
|
||||
}))
|
||||
return {
|
||||
id: 'rc-' + node.categoryId,
|
||||
@@ -426,13 +433,30 @@ async function handleConfigSubmit() {
|
||||
// ─── Edit Good (replaces detail — click opens edit directly) ───
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editGood = ref<Good | null>(null)
|
||||
const editDetailLoading = ref(false)
|
||||
const detailSyncing = ref(false)
|
||||
const editGood = ref<Good | GoodDetail | null>(null)
|
||||
const editForm = ref({
|
||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||
categoryId: '', tagIds: [] as string[], positionId: '',
|
||||
})
|
||||
|
||||
function openEdit(g: Good) {
|
||||
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 ?? [])
|
||||
|
||||
async function openEdit(g: Good) {
|
||||
editGood.value = g
|
||||
editForm.value = {
|
||||
id: g.id, goodName: g.goodName,
|
||||
@@ -444,6 +468,46 @@ function openEdit(g: Good) {
|
||||
positionId: g.positionId || '',
|
||||
}
|
||||
editVisible.value = true
|
||||
editDetailLoading.value = true
|
||||
try {
|
||||
editGood.value = await goodsApi.getGoodById(g.id)
|
||||
} catch {
|
||||
ElMessage.warning('商品详情加载失败,当前显示列表数据')
|
||||
} finally {
|
||||
editDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncOriginDetail(data: any) {
|
||||
if (!data.sdsGoodId || syncingOriginGoodIds.value.has(data.sdsGoodId)) return
|
||||
syncingOriginGoodIds.value = new Set(syncingOriginGoodIds.value).add(data.sdsGoodId)
|
||||
try {
|
||||
const result = await syncApi.syncOneProductDetail(data.sdsGoodId)
|
||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||
await refreshRightTree()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
|
||||
} finally {
|
||||
const next = new Set(syncingOriginGoodIds.value)
|
||||
next.delete(data.sdsGoodId)
|
||||
syncingOriginGoodIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncOneDetail() {
|
||||
const goodId = editGood.value?.originGood?.sdsGoodId
|
||||
if (!goodId) return
|
||||
detailSyncing.value = true
|
||||
try {
|
||||
const result = await syncApi.syncOneProductDetail(goodId)
|
||||
editGood.value = await goodsApi.getGoodById(editGood.value!.id)
|
||||
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
|
||||
await Promise.all([refreshLeftTree(), refreshRightTree()])
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
|
||||
} finally {
|
||||
detailSyncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
@@ -1351,7 +1415,22 @@ onMounted(() => loadAll())
|
||||
v-else
|
||||
class="og-badge og-badge--warn"
|
||||
>未配置</span>
|
||||
<span
|
||||
class="og-badge"
|
||||
:class="data.hasDetail ? 'og-badge--detail' : 'og-badge--missing'"
|
||||
:title="data.detailSyncedAt ? `详情同步于 ${new Date(data.detailSyncedAt).toLocaleString()}` : '尚未同步商品详情'"
|
||||
>
|
||||
{{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }}
|
||||
</span>
|
||||
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
|
||||
<el-button
|
||||
size="small"
|
||||
link
|
||||
:icon="Refresh"
|
||||
:loading="syncingOriginGoodIds.has(data.sdsGoodId)"
|
||||
:title="data.hasDetail ? '重新同步商品详情' : '同步商品详情'"
|
||||
@click.stop="handleSyncOriginDetail(data)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="data.configuredCount > 0"
|
||||
size="small" link :icon="Aim" title="定位到官网商品"
|
||||
@@ -1501,7 +1580,7 @@ onMounted(() => loadAll())
|
||||
</el-dialog>
|
||||
|
||||
<!-- Edit Good Modal (direct edit — no separate detail step) -->
|
||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
|
||||
<!-- Origin product reference -->
|
||||
<div v-if="editGood?.originGood" class="edit-og-ref">
|
||||
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
|
||||
@@ -1509,9 +1588,28 @@ onMounted(() => loadAll())
|
||||
<div class="edit-og-label">关联原产品</div>
|
||||
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
|
||||
<div class="edit-og-sub">SDS ID: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
|
||||
<div class="edit-og-status">
|
||||
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
|
||||
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
|
||||
</el-tag>
|
||||
<span v-if="editGood.originGood.detailSyncedAt">
|
||||
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
|
||||
</span>
|
||||
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
|
||||
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
|
||||
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:icon="Refresh"
|
||||
:loading="detailSyncing"
|
||||
@click="handleSyncOneDetail"
|
||||
>同步详情</el-button>
|
||||
</div>
|
||||
<el-form label-width="80px" style="margin-top: 16px">
|
||||
<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="上传图片" />
|
||||
@@ -1538,6 +1636,57 @@ onMounted(() => loadAll())
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-tabs v-if="editOriginDetail" class="detail-tabs">
|
||||
<el-tab-pane label="商品详情">
|
||||
<el-descriptions :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 :label="`尺码表 (${editSizeRows.length})`">
|
||||
<el-table :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 :label="`包装规格 (${editPackageRows.length})`">
|
||||
<el-table :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 :label="`SKU (${editVariants.length})`">
|
||||
<el-table :data="editVariants" border max-height="320">
|
||||
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
|
||||
<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-tabs>
|
||||
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
|
||||
<template #footer>
|
||||
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -1826,6 +1975,8 @@ onMounted(() => loadAll())
|
||||
.og-badge--warn {
|
||||
color: #ff6800; background: #fff2e8;
|
||||
}
|
||||
.og-badge--detail { color: #337ecc; background: #ecf5ff; }
|
||||
.og-badge--missing { color: #909399; background: #f4f4f5; }
|
||||
.og-config-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
@@ -1852,6 +2003,9 @@ onMounted(() => loadAll())
|
||||
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
|
||||
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
|
||||
.edit-og-meta { flex: 1; min-width: 0; }
|
||||
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
|
||||
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
|
||||
|
||||
/* Config modal */
|
||||
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user