diff --git a/apps/admin/src/api/goods.ts b/apps/admin/src/api/goods.ts index f575070..66ad5c9 100644 --- a/apps/admin/src/api/goods.ts +++ b/apps/admin/src/api/goods.ts @@ -1,6 +1,7 @@ import request from './request' import type { Good, + GoodDetail, CreateGoodRequest, UpdateGoodRequest, BatchCreateGoodsRequest, @@ -17,7 +18,7 @@ export const goodsApi = { // Get good by id getGoodById: (id: string) => { - return request.get(`/goods/${id}`) + return request.get(`/goods/${id}`) }, // Create good @@ -44,4 +45,4 @@ export const goodsApi = { batchUpdatePriority: (data: BatchPriorityRequest) => { return request.patch('/goods/batch-priority', data) }, -} \ No newline at end of file +} diff --git a/apps/admin/src/api/sync.ts b/apps/admin/src/api/sync.ts index 0b83dc5..37ebce1 100644 --- a/apps/admin/src/api/sync.ts +++ b/apps/admin/src/api/sync.ts @@ -10,6 +10,16 @@ export const syncApi = { return request.post('/sync/categories') }, + syncProductDetails: () => { + return request.post('/sync/product-details') + }, + + syncOneProductDetail: (goodId: string) => { + return request.post( + `/sync/products/${goodId}/detail`, + ) + }, + getSyncStatus: (limit?: number) => { return request.get('/sync/status', { params: limit ? { limit } : undefined, diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 554f008..00d143d 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -44,6 +44,35 @@ export interface Good { updatedAt: string } +export interface OriginGoodDetail { + productCode?: string | null + englishName?: string | null + productionCycleHours?: number | null + minWeightG?: string | null + productionProcess?: string | null + materialDescription?: string | null + sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null + packageSpecs?: { rows?: any[] } | null + syncedAt?: string | null + [key: string]: unknown +} + +export interface OriginGoodVariant { + sdsVariantId: string + sku: string + sizeName?: string | null + colorName?: string | null + colorHex?: string | null + price?: string | null + enabled: boolean + [key: string]: unknown +} + +export interface GoodDetail extends Good { + originDetail: OriginGoodDetail | null + variants: OriginGoodVariant[] +} + export interface CreateGoodRequest { goodName: string goodImage?: string @@ -246,6 +275,12 @@ export interface OriginGood { delisted?: boolean createdAt: string updatedAt: string + hasDetail?: boolean + detailSyncedAt?: string | null + variantCount?: number + sizeRowCount?: number + packageRowCount?: number + productCode?: string | null } // Origin Goods Tree types @@ -259,6 +294,11 @@ export interface OriginGoodsTreeNode { configuredCount: number configuredCountries: string[] configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[] + hasDetail: boolean + detailSyncedAt: string | null + variantCount: number + sizeRowCount: number + packageRowCount: number } export interface OriginGoodsTreeCategoryNode { @@ -280,13 +320,11 @@ export interface OriginGoodsTreeResponse { // Sync types export interface SyncLog { id: string - type: 'CATEGORY' | 'PRODUCT' - status: 'SUCCESS' | 'FAILED' - message?: string - startTime: string - endTime?: string - errorCount?: number - createdAt: string + type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS' + status: 'RUNNING' | 'SUCCESS' | 'FAILED' + message: string | null + startedAt: string + finishedAt: string | null } // Filter types diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index fb04833..1a14a8c 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -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()) 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(null) +const editDetailLoading = ref(false) +const detailSyncing = ref(false) +const editGood = ref(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, 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" >未配置 + + {{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }} + ¥{{ data.goodPrice }} + loadAll()) - +
@@ -1509,9 +1588,28 @@ onMounted(() => loadAll())
关联原产品
{{ editGood.originGood.goodName }}
SDS ID: {{ editGood.originGood.sdsGoodId }}
+
+ + {{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }} + + + {{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }} + + SKU {{ editGood.originGood.variantCount || 0 }} + 尺码 {{ editGood.originGood.sizeRowCount || 0 }} + 包装 {{ editGood.originGood.packageRowCount || 0 }} +
+ 同步详情 - + @@ -1538,6 +1636,57 @@ onMounted(() => loadAll()) + + + + + {{ editOriginDetail.productCode || '-' }} + {{ editOriginDetail.englishName || '-' }} + {{ editOriginDetail.productionCycleHours ?? '-' }} 小时 + {{ editOriginDetail.minWeightG ?? '-' }} g + {{ editOriginDetail.productionProcess || '-' }} + {{ editOriginDetail.materialDescription || '-' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +