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/components.d.ts b/apps/admin/src/components.d.ts index b79c8fe..e39958f 100644 --- a/apps/admin/src/components.d.ts +++ b/apps/admin/src/components.d.ts @@ -18,6 +18,7 @@ declare module 'vue' { ElButton: typeof import('element-plus/es')['ElButton'] ElCascader: typeof import('element-plus/es')['ElCascader'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] @@ -41,6 +42,7 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] 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/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts new file mode 100644 index 0000000..362ed98 --- /dev/null +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { sameOriginGroup, truncateToProcess } from './origin-name'; + +describe('truncateToProcess', () => { + it('keeps first 3 dash segments (drops warehouse)', () => { + expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓')) + .toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'); + }); + it('is identity for 3-segment names', () => { + expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面')) + .toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'); + }); + it('handles malformed names without country prefix', () => { + expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花')) + .toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'); + }); + it('returns empty for null/empty', () => { + expect(truncateToProcess(null)).toBe(''); + expect(truncateToProcess('')).toBe(''); + }); +}); + +describe('sameOriginGroup', () => { + it('matches same product across warehouses', () => { + expect(sameOriginGroup( + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓', + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓', + )).toBe(true); + }); + it('does not match different SKU', () => { + expect(sameOriginGroup( + '美国(包邮)180g纯棉T恤成人款-DG001-单面印花', + '美国(包邮)180g纯棉T恤成人款-DG002-单面印花', + )).toBe(false); + }); + it('does not match different country', () => { + expect(sameOriginGroup( + '美国(包邮)T恤-DG001-单面印花', + '德国(不包邮)T恤-DG001-单面印花', + )).toBe(false); + }); + it('does not match empty names', () => { + expect(sameOriginGroup(null, 'x-y-z')).toBe(false); + expect(sameOriginGroup('', '')).toBe(false); + }); +}); diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts new file mode 100644 index 0000000..7da4a09 --- /dev/null +++ b/apps/admin/src/utils/origin-name.ts @@ -0,0 +1,20 @@ +/** + * 原产品名匹配规则(见 data-cleaning/README.md 命名调查): + * `国家(物流备注)品名-SKU-工艺位置[-仓库名]` + * 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。 + * 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。 + */ +const KEEP_SEGMENTS = 3; + +export function truncateToProcess(name: string | null | undefined): string { + if (!name) return ''; + return name.split('-').slice(0, KEEP_SEGMENTS).join('-'); +} + +export function sameOriginGroup( + a: string | null | undefined, + b: string | null | undefined, +): boolean { + const ka = truncateToProcess(a); + return ka !== '' && ka === truncateToProcess(b); +} diff --git a/apps/admin/src/views/categories/CategoriesView.vue b/apps/admin/src/views/categories/CategoriesView.vue index 80ebaeb..79d2d0b 100644 --- a/apps/admin/src/views/categories/CategoriesView.vue +++ b/apps/admin/src/views/categories/CategoriesView.vue @@ -52,7 +52,6 @@ const dialogLoading = ref(false) const dialogForm = reactive({ categoryName: '', categoryIcon: '', - parentCategoryId: '', }) const dialogRules = { @@ -146,7 +145,7 @@ onMounted(fetchTree) > -