diff --git a/apps/admin/package.json b/apps/admin/package.json index 5c693e5..5599f50 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,32 +1,34 @@ -{ - "name": "@inkreach/admin", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vue-tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@element-plus/icons-vue": "^2.3.2", - "@vueuse/core": "^14.3.0", - "axios": "^1.18.0", - "dayjs": "^1.11.21", - "element-plus": "^2.14.2", - "pinia": "^3.0.4", - "vue": "^3.5.34", - "vue-router": "^4.6.4" - }, - "devDependencies": { - "@types/node": "^24.12.3", - "@vitejs/plugin-vue": "^6.0.6", - "@vue/tsconfig": "^0.9.1", - "sass": "^1.101.0", - "typescript": "~6.0.2", - "unplugin-auto-import": "^21.0.0", - "unplugin-vue-components": "^32.1.0", - "vite": "^8.0.12", - "vue-tsc": "^3.2.8" - } -} +{ + "name": "@inkreach/admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@vueuse/core": "^14.3.0", + "axios": "^1.18.0", + "dayjs": "^1.11.21", + "element-plus": "^2.14.2", + "pinia": "^3.0.4", + "vue": "^3.5.34", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.12.3", + "@vitejs/plugin-vue": "^6.0.6", + "@vue/tsconfig": "^0.9.1", + "sass": "^1.101.0", + "typescript": "~6.0.2", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.1.0", + "vite": "^8.0.12", + "vitest": "^4.1.10", + "vue-tsc": "^3.2.8" + } +} diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 4cffdef..512ac27 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -24,7 +24,7 @@ export interface User { } // Good types -export interface Good { +export interface Good { id: string goodName: string goodImage: string | null @@ -42,81 +42,92 @@ export interface Good { position?: Position createdAt: string 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 CustomGoodVariantRequest { - sku: string - sizeId?: string | null - sizeName?: string | null - colorId?: string | null - colorName?: string | null - colorHex?: string | null - imageUrl?: string | null - price?: string | null - originalPrice?: string | null - weightG?: string | null - boxLengthCm?: string | null - boxWidthCm?: string | null - boxHeightCm?: string | null - designData?: Record | null - enabled?: boolean - sortOrder?: number -} - -export interface CustomGoodDetailRequest { - productCode?: string | null - englishName?: string | null - productionCycleHours?: number | null - minWeightG?: string | null - productionProcess?: string | null - materialDescription?: string | null - blankDesignUrl?: string | null - detailsPageVideoUrl?: string | null - textureName?: string | null - reminder?: string | null - productPerformance?: string | null - applicableScenarios?: string | null - washingInstructions?: string | null - specialDescription?: string | null - designExplanation?: string | null - designArea?: string | null - pictureRequest?: string | null - sizeChart?: Record | null - packageSpecs?: Record | null - options?: Record | null - media?: Record | null -} - -export interface GoodDetail extends Good { - originDetail: OriginGoodDetail | null - variants: OriginGoodVariant[] -} + mergedOriginGoods?: MergedOriginGoodSummary[] +} -export interface CreateGoodRequest { +export interface MergedOriginGoodSummary { + id: string + sdsGoodId: string + goodName: string | null + goodImage: string | null + goodPrice: string | null + hasDetail: boolean + variantCount: number +} + +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 CustomGoodVariantRequest { + sku: string + sizeId?: string | null + sizeName?: string | null + colorId?: string | null + colorName?: string | null + colorHex?: string | null + imageUrl?: string | null + price?: string | null + originalPrice?: string | null + weightG?: string | null + boxLengthCm?: string | null + boxWidthCm?: string | null + boxHeightCm?: string | null + designData?: Record | null + enabled?: boolean + sortOrder?: number +} + +export interface CustomGoodDetailRequest { + productCode?: string | null + englishName?: string | null + productionCycleHours?: number | null + minWeightG?: string | null + productionProcess?: string | null + materialDescription?: string | null + blankDesignUrl?: string | null + detailsPageVideoUrl?: string | null + textureName?: string | null + reminder?: string | null + productPerformance?: string | null + applicableScenarios?: string | null + washingInstructions?: string | null + specialDescription?: string | null + designExplanation?: string | null + designArea?: string | null + pictureRequest?: string | null + sizeChart?: Record | null + packageSpecs?: Record | null + options?: Record | null + media?: Record | null +} + +export interface GoodDetail extends Good { + originDetail: OriginGoodDetail | null + variants: OriginGoodVariant[] +} + +export interface CreateGoodRequest { goodName: string goodImage?: string originGoodId: number @@ -124,29 +135,30 @@ export interface CreateGoodRequest { categoryId: number tagIds?: number[] positionId?: number - goodPriority?: number -} - -export interface CreateCustomGoodRequest { - goodName: string - goodImage?: string - goodPrice?: string | null - countryId: number - categoryId: number - tagIds?: number[] - positionId?: number - goodPriority?: number - detail?: CustomGoodDetailRequest - variants?: CustomGoodVariantRequest[] -} - -export interface UpdateCustomGoodContentRequest { - goodName?: string - goodImage?: string | null - goodPrice?: string | null - detail?: CustomGoodDetailRequest - variants?: CustomGoodVariantRequest[] -} + goodPriority?: number + mergedOriginGoodIds?: number[] +} + +export interface CreateCustomGoodRequest { + goodName: string + goodImage?: string + goodPrice?: string | null + countryId: number + categoryId: number + tagIds?: number[] + positionId?: number + goodPriority?: number + detail?: CustomGoodDetailRequest + variants?: CustomGoodVariantRequest[] +} + +export interface UpdateCustomGoodContentRequest { + goodName?: string + goodImage?: string | null + goodPrice?: string | null + detail?: CustomGoodDetailRequest + variants?: CustomGoodVariantRequest[] +} export interface UpdateGoodRequest { goodName?: string @@ -157,6 +169,7 @@ export interface UpdateGoodRequest { tagIds?: number[] positionId?: number | null goodPriority?: number + mergedOriginGoodIds?: number[] } export interface BatchCreateItem { @@ -329,28 +342,28 @@ export interface UpdatePositionRequest { } // Origin Good types -export interface OriginGood { +export interface OriginGood { id: string goodName: string | null goodImage: string | null goodPrice: string | null - sdsGoodId: string - source: 'SDS' | 'CUSTOM' - isCustom: boolean + sdsGoodId: string + source: 'SDS' | 'CUSTOM' + isCustom: boolean sdsCategoryId: string | null delisted?: boolean createdAt: string - updatedAt: string - hasDetail?: boolean - detailSyncedAt?: string | null - variantCount?: number - sizeRowCount?: number - packageRowCount?: number - productCode?: string | null + updatedAt: string + hasDetail?: boolean + detailSyncedAt?: string | null + variantCount?: number + sizeRowCount?: number + packageRowCount?: number + productCode?: string | null } // Origin Goods Tree types -export interface OriginGoodsTreeNode { +export interface OriginGoodsTreeNode { id: string goodName: string goodImage: string | null @@ -359,12 +372,12 @@ export interface OriginGoodsTreeNode { delisted: boolean 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 + 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 { @@ -384,14 +397,14 @@ export interface OriginGoodsTreeResponse { } // Sync types -export interface SyncLog { - id: string - type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS' - status: 'RUNNING' | 'SUCCESS' | 'FAILED' - message: string | null - startedAt: string - finishedAt: string | null -} +export interface SyncLog { + id: string + type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS' + status: 'RUNNING' | 'SUCCESS' | 'FAILED' + message: string | null + startedAt: string + finishedAt: string | null +} // Filter types export interface GoodsFilter { diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 3c438f1..bf1a781 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, Refresh, + Plus, Edit, Delete, Search, Top, Refresh, FolderAdd, Aim, ArrowDown, } from '@element-plus/icons-vue' import type { - CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, + CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, OriginGoodsTreeResponse, } from '@/types' import { goodsApi } from '@/api/goods' @@ -15,8 +15,9 @@ import { countriesApi } from '@/api/countries' import { categoriesApi } from '@/api/categories' import { tagsApi } from '@/api/tags' import { tagGroupsApi } from '@/api/tag-groups' -import { originGoodsApi } from '@/api/origin-goods' -import { syncApi } from '@/api/sync' +import { originGoodsApi } from '@/api/origin-goods' +import { syncApi } from '@/api/sync' +import { sameOriginGroup } from '@/utils/origin-name' const mode = ref<'category' | 'country' | 'global'>('category') const loading = ref(false) @@ -51,9 +52,9 @@ const treeProps = { label: 'label', children: 'children' } const leftPct = ref(55) const showAllLeft = ref(false) -const showAllRight = ref(false) -const showUnconfiguredOnly = ref(false) -const syncingOriginGoodIds = ref(new Set()) +const showAllRight = ref(false) +const showUnconfiguredOnly = ref(false) +const syncingOriginGoodIds = ref(new Set()) function onSplitterMouseDown(e: MouseEvent) { e.preventDefault() @@ -168,9 +169,9 @@ function goodToNode(g: Good): any { originGoodName: g.originGood?.goodName || null, originGoodImage: g.originGood?.goodImage || null, originGoodPrice: g.originGood?.goodPrice || null, - sdsGoodId: g.originGood?.sdsGoodId || null, - isCustom: g.originGood?.isCustom === true, - originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)), + sdsGoodId: g.originGood?.sdsGoodId || null, + isCustom: g.originGood?.isCustom === true, + originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)), } } @@ -260,17 +261,18 @@ function buildRightTree(tree: OriginGoodsTreeResponse) { label: og.goodName, isOG: true, rawId: og.id, + parentId: 'rc-' + node.categoryId, goodName: og.goodName, goodImage: og.goodImage, goodPrice: og.goodPrice, 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, + 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, @@ -364,10 +366,48 @@ const configForm = ref({ countryId: '', cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[], positionId: '', goodImage: '', }) +const configSiblings = ref([]) +const configChecked = ref([]) +const configPrimaryId = ref('') + +const checkedSiblingNodes = computed(() => + configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))), +) + +function findOgNodeById(rawId: string | number): any { + let found: any = null + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return } + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return found +} + +function collectSiblings(ogNode: any): any[] { + const result: any[] = [] + function traverse(nodes: any[]) { + for (const n of nodes) { + if (n.isOG && n.parentId === ogNode.parentId && String(n.rawId) !== String(ogNode.rawId) && !n.configuredCount) result.push(n) + if (n.children?.length) traverse(n.children) + } + } + traverse(rightTreeData.value) + return result +} function openConfigModal(og: any, dropTarget: any) { configOG.value = og configDropTarget.value = dropTarget + const ogNode = findOgNodeById(og.rawId) + const siblings = ogNode ? collectSiblings(ogNode) : [] + configSiblings.value = siblings + configPrimaryId.value = String(og.rawId) + configChecked.value = siblings + .filter((s) => sameOriginGroup(ogNode?.goodName ?? og.goodName, s.goodName)) + .map((s) => String(s.rawId)) configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' } if (dropTarget) { if (mode.value === 'category') { @@ -377,10 +417,10 @@ function openConfigModal(og: any, dropTarget: any) { configForm.value.countryId = dropTarget.id } } - configVisible.value = true + configVisible.value = true } -function openConfigFromRightTree(data: any) { +function openConfigFromRightTree(data: any) { openConfigModal({ rawId: data.rawId, goodName: data.goodName, @@ -390,11 +430,11 @@ function openConfigFromRightTree(data: any) { }, null) } -function onConfigCascaderChange(val: string[]) { - configForm.value.categoryId = val.length ? val[val.length - 1] : '' +function onConfigCascaderChange(val: any) { + configForm.value.categoryId = val.length ? val[val.length - 1] : '' } -async function handleConfigSubmit() { +async function handleConfigSubmit() { if (!configForm.value.countryId) { ElMessage.warning('请选择国家'); return } if (!configForm.value.categoryId) { ElMessage.warning('请选择分类'); return } configLoading.value = true @@ -402,7 +442,10 @@ async function handleConfigSubmit() { await goodsApi.createGood({ goodName: configOG.value.goodName, goodImage: configForm.value.goodImage || undefined, - originGoodId: Number(configOG.value.rawId), + originGoodId: Number(configPrimaryId.value || configOG.value.rawId), + mergedOriginGoodIds: configChecked.value + .filter((id) => id !== configPrimaryId.value) + .map(Number), countryId: Number(configForm.value.countryId), categoryId: Number(configForm.value.categoryId), tagIds: configForm.value.tagIds.map(Number), @@ -415,152 +458,152 @@ async function handleConfigSubmit() { } catch (e: any) { ElMessage.error(e?.response?.data?.message || '配置失败') } finally { configLoading.value = false } -} - -// ─── Custom Good ─── -const customVisible = ref(false) -const customLoading = ref(false) -const customForm = ref({ - goodName: '', goodImage: '', goodPrice: '', countryId: '', - cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[], - goodPriority: 0, -}) - -function openCustomCreate() { - customForm.value = { - goodName: '', goodImage: '', goodPrice: '', countryId: '', - cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0, - } - customVisible.value = true -} - -function onCustomCascaderChange(val: any) { - const path = Array.isArray(val) ? val : [] - customForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' -} - -async function handleCustomCreate() { - if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return } - if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return } - if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return } - customLoading.value = true - try { - const created = await goodsApi.createCustomGood({ - goodName: customForm.value.goodName.trim(), - goodImage: customForm.value.goodImage || undefined, - goodPrice: customForm.value.goodPrice || null, - countryId: Number(customForm.value.countryId), - categoryId: Number(customForm.value.categoryId), - tagIds: customForm.value.tagIds.map(Number), - goodPriority: customForm.value.goodPriority, - detail: {}, - }) - ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU') - customVisible.value = false - await refreshLeftTree() - await openEdit(created) - } catch (error: any) { - ElMessage.error(error?.response?.data?.message || '自定义商品创建失败') - } finally { customLoading.value = false } -} - -// ─── Edit Good (replaces detail — click opens edit directly) ─── -const editVisible = ref(false) -const editLoading = ref(false) -const editDetailLoading = ref(false) -const detailSyncing = ref(false) -const editGood = ref(null) +} + +// ─── Custom Good ─── +const customVisible = ref(false) +const customLoading = ref(false) +const customForm = ref({ + goodName: '', goodImage: '', goodPrice: '', countryId: '', + cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[], + goodPriority: 0, +}) + +function openCustomCreate() { + customForm.value = { + goodName: '', goodImage: '', goodPrice: '', countryId: '', + cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0, + } + customVisible.value = true +} + +function onCustomCascaderChange(val: any) { + const path = Array.isArray(val) ? val : [] + customForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' +} + +async function handleCustomCreate() { + if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return } + if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return } + if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return } + customLoading.value = true + try { + const created = await goodsApi.createCustomGood({ + goodName: customForm.value.goodName.trim(), + goodImage: customForm.value.goodImage || undefined, + goodPrice: customForm.value.goodPrice || null, + countryId: Number(customForm.value.countryId), + categoryId: Number(customForm.value.categoryId), + tagIds: customForm.value.tagIds.map(Number), + goodPriority: customForm.value.goodPriority, + detail: {}, + }) + ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU') + customVisible.value = false + await refreshLeftTree() + await openEdit(created) + } catch (error: any) { + ElMessage.error(error?.response?.data?.message || '自定义商品创建失败') + } finally { customLoading.value = false } +} + +// ─── Edit Good (replaces detail — click opens edit directly) ─── +const editVisible = ref(false) +const editLoading = ref(false) +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: '', }) -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 ?? []) -const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true) -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, - }) -} - -async function openEdit(g: Good) { - editGood.value = g +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 ?? []) +const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true) +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, + }) +} + +async function openEdit(g: Good) { + editGood.value = g editForm.value = { id: g.id, goodName: g.goodName, goodImage: g.goodImage || g.originGood?.goodImage || '', @@ -569,132 +612,132 @@ async function openEdit(g: Good) { categoryId: g.categoryId, tagIds: (g.tags || []).map(t => t.id), positionId: g.positionId || '', - } - editVisible.value = true - editDetailLoading.value = true - try { - const detail = await goodsApi.getGoodById(g.id) - editGood.value = detail - if (detail.originGood?.isCustom) fillCustomContent(detail) - } 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() { - if (editIsCustom.value) return - 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() { - let customPayload: any = null - if (editIsCustom.value) { - let sizeChart: Record - let packageSpecs: Record - let options: Record - let media: Record - 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 + } + editVisible.value = true + editDetailLoading.value = true try { - await goodsApi.updateGood(editForm.value.id, { + const detail = await goodsApi.getGoodById(g.id) + editGood.value = detail + if (detail.originGood?.isCustom) fillCustomContent(detail) + } 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() { + if (editIsCustom.value) return + 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() { + let customPayload: any = null + if (editIsCustom.value) { + let sizeChart: Record + let packageSpecs: Record + let options: Record + let media: Record + 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), tagIds: editForm.value.tagIds.map(Number), positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, - } as any) - if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload) + } as any) + if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload) ElMessage.success('更新成功') editVisible.value = false refreshLeftTree() @@ -704,10 +747,10 @@ async function handleEditSubmit() { } finally { editLoading.value = false } } -function onEditCascaderChange(val: any) { - const path = Array.isArray(val) ? val : [] - editForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' -} +function onEditCascaderChange(val: any) { + const path = Array.isArray(val) ? val : [] + editForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' +} async function handleDeleteGood(g: Good) { try { @@ -1078,13 +1121,13 @@ const tagTreeData = computed(() => { })), })) - const ungrouped = allTags.value + const ungrouped: TreeNode[] = allTags.value .filter((t) => !t.tagGroupId) .sort((a, b) => a.tagName.localeCompare(b.tagName)) .map((t) => ({ id: `t-${t.id}`, rawId: t.id, - type: 'tag', + type: 'tag' as const, label: t.tagName, })) @@ -1168,7 +1211,7 @@ async function quickCreateCountry(targetForm: () => void) { confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称', }) if (!value.trim()) return - const res = await countriesApi.createCountry({ countryName: value.trim() } as any) as any + await countriesApi.createCountry({ countryName: value.trim() } as any) await reloadCountries() targetForm() ElMessage.success('已创建并选中') @@ -1181,7 +1224,7 @@ async function quickCreateTag(targetForm: () => void) { confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称', }) if (!value.trim()) return - const res = await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any) as any + await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any) await reloadTags() targetForm() ElMessage.success('已创建并选中') @@ -1438,8 +1481,8 @@ onMounted(() => loadAll()) - 搜索 - 新增自定义商品 + 搜索 + 新增自定义商品
@@ -1519,7 +1562,7 @@ onMounted(() => loadAll())
-
{{ data.isCustom ? '来源' : '原产品' }}
+
{{ data.isCustom ? '来源' : '原产品' }}
{{ data.originGoodName }}
@@ -1529,14 +1572,14 @@ onMounted(() => loadAll()) - + -
- 下架 - 自定义 +
+ 下架 + 自定义 {{ data.country }} loadAll()) class="og-badge og-badge--ok" title="已配置 {{ data.configuredCount }} 国" >已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }} - 未配置 - - {{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }} - - ¥{{ data.goodPrice }} - + 未配置 + + {{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }} + + ¥{{ data.goodPrice }} + loadAll())
- + - +
{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}
- + {{ configDropTarget?.label }} + +
+
勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主源决定价格与详情。
+
+ + 主源:{{ configOG.goodName }} + {{ s.goodName }} + +
+ + + {{ s.goodName }} + + +
+
@@ -1741,7 +1800,7 @@ onMounted(() => loadAll()) - +
@@ -1751,198 +1810,198 @@ onMounted(() => loadAll()) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
{{ editIsCustom ? '自定义商品' : '关联原产品' }}
-
{{ editGood.originGood.goodName }}
-
{{ editIsCustom ? '自定义 ID' : '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 }} -
-
- 同步详情 -
- +
{{ editIsCustom ? '自定义商品' : '关联原产品' }}
+
{{ editGood.originGood.goodName }}
+
{{ editIsCustom ? '自定义 ID' : '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 }} +
+
+ 同步详情 + +
- + - +
- + - +
- +
-
- - - -
- - - - - - - 小时 - - - - - - - - - - - - - - - - - - - {{ editOriginDetail.productCode || '-' }} - {{ editOriginDetail.englishName || '-' }} - {{ editOriginDetail.productionCycleHours ?? '-' }} 小时 - {{ editOriginDetail.minWeightG ?? '-' }} g - {{ editOriginDetail.productionProcess || '-' }} - {{ editOriginDetail.materialDescription || '-' }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -