From b04623ebdd73ea4197211cdd7cf793e76651ef16 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 21 Aug 2026 14:45:42 +0800 Subject: [PATCH] feat(goods): add editable custom products --- apps/admin/src/api/goods.ts | 10 + apps/admin/src/components.d.ts | 3 + apps/admin/src/types/index.ts | 66 ++++ apps/admin/src/views/goods/GoodsView.vue | 342 +++++++++++++++++- apps/admin/src/views/sync/SyncView.vue | 2 +- .../migration.sql | 6 + apps/api/prisma/schema.prisma | 7 + apps/api/src/goods/dto/custom-good.dto.ts | 236 ++++++++++++ apps/api/src/goods/dto/good.dto.ts | 5 + apps/api/src/goods/goods.controller.ts | 19 + apps/api/src/goods/goods.service.spec.ts | 45 +++ apps/api/src/goods/goods.service.ts | 233 +++++++++++- .../src/origin-goods/origin-goods.service.ts | 11 +- apps/api/src/public/public.service.spec.ts | 30 ++ apps/api/src/sync/sync.service.spec.ts | 25 +- apps/api/src/sync/sync.service.ts | 48 ++- 16 files changed, 1045 insertions(+), 43 deletions(-) create mode 100644 apps/api/prisma/migrations/20260823000000_add_custom_origin_goods/migration.sql create mode 100644 apps/api/src/goods/dto/custom-good.dto.ts diff --git a/apps/admin/src/api/goods.ts b/apps/admin/src/api/goods.ts index 66ad5c9..f3a40ec 100644 --- a/apps/admin/src/api/goods.ts +++ b/apps/admin/src/api/goods.ts @@ -8,6 +8,8 @@ import type { BatchPriorityRequest, GoodsFilter, PaginatedResult, + CreateCustomGoodRequest, + UpdateCustomGoodContentRequest, } from '@/types' export const goodsApi = { @@ -26,6 +28,14 @@ export const goodsApi = { return request.post('/goods', data) }, + createCustomGood: (data: CreateCustomGoodRequest) => { + return request.post('/goods/custom', data) + }, + + updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => { + return request.patch(`/goods/${id}/custom-content`, data) + }, + // Update good updateGood: (id: string, data: UpdateGoodRequest) => { return request.patch(`/goods/${id}`, data) diff --git a/apps/admin/src/components.d.ts b/apps/admin/src/components.d.ts index 1a58314..b79c8fe 100644 --- a/apps/admin/src/components.d.ts +++ b/apps/admin/src/components.d.ts @@ -11,6 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] ElAside: typeof import('element-plus/es')['ElAside'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] @@ -33,6 +34,7 @@ declare module 'vue' { ElImage: typeof import('element-plus/es')['ElImage'] ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] @@ -42,6 +44,7 @@ declare module 'vue' { ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTabPane: typeof import('element-plus/es')['ElTabPane'] diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 00d143d..daa4a50 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -68,6 +68,49 @@ export interface OriginGoodVariant { [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[] @@ -84,6 +127,27 @@ export interface CreateGoodRequest { 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[] +} + export interface UpdateGoodRequest { goodName?: string goodImage?: string | null @@ -271,6 +335,8 @@ export interface OriginGood { goodImage: string | null goodPrice: string | null sdsGoodId: string + source: 'SDS' | 'CUSTOM' + isCustom: boolean sdsCategoryId: string | null delisted?: boolean createdAt: string diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 1a14a8c..3a9060e 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -170,7 +170,8 @@ function goodToNode(g: Good): any { originGoodImage: g.originGood?.goodImage || null, originGoodPrice: g.originGood?.goodPrice || null, sdsGoodId: g.originGood?.sdsGoodId || null, - originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)), + isCustom: g.originGood?.isCustom === true, + originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)), } } @@ -430,6 +431,67 @@ async function handleConfigSubmit() { } finally { configLoading.value = false } } +// ─── Custom Good ─── +const customVisible = ref(false) +const customLoading = ref(false) +const customPositions = ref([]) +const customForm = ref({ + goodName: '', goodImage: '', goodPrice: '', countryId: '', + cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[], + positionId: '', goodPriority: 0, +}) + +function openCustomCreate() { + customForm.value = { + goodName: '', goodImage: '', goodPrice: '', countryId: '', + cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodPriority: 0, + } + customPositions.value = [] + customVisible.value = true +} + +async function loadCustomPositions() { + const params: any = { page: 1, pageSize: 200 } + if (customForm.value.countryId) params.countryId = customForm.value.countryId + if (customForm.value.categoryId) params.categoryId = customForm.value.categoryId + try { + const res = await positionsApi.getPositionsList(params) as any + customPositions.value = Array.isArray(res) ? res : (res.items ?? []) + } catch { customPositions.value = [] } +} + +function onCustomCascaderChange(val: any) { + const path = Array.isArray(val) ? val : [] + customForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' + loadCustomPositions() +} + +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), + positionId: customForm.value.positionId ? Number(customForm.value.positionId) : undefined, + 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) @@ -455,6 +517,75 @@ const editSizeRows = computed(() => { })) }) 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 @@ -468,9 +599,12 @@ async function openEdit(g: Good) { positionId: g.positionId || '', } editVisible.value = true + loadEditPositions() editDetailLoading.value = true try { - editGood.value = await goodsApi.getGoodById(g.id) + const detail = await goodsApi.getGoodById(g.id) + editGood.value = detail + if (detail.originGood?.isCustom) fillCustomContent(detail) } catch { ElMessage.warning('商品详情加载失败,当前显示列表数据') } finally { @@ -495,6 +629,7 @@ async function handleSyncOriginDetail(data: any) { } async function handleSyncOneDetail() { + if (editIsCustom.value) return const goodId = editGood.value?.originGood?.sdsGoodId if (!goodId) return detailSyncing.value = true @@ -511,6 +646,73 @@ async function handleSyncOneDetail() { } 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, { @@ -521,6 +723,7 @@ async function handleEditSubmit() { 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) ElMessage.success('更新成功') editVisible.value = false refreshLeftTree() @@ -530,8 +733,20 @@ async function handleEditSubmit() { } finally { editLoading.value = false } } -function onEditCascaderChange(val: string[]) { - editForm.value.categoryId = val.length ? val[val.length - 1] : '' +function onEditCascaderChange(val: any) { + const path = Array.isArray(val) ? val : [] + editForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' + loadEditPositions() +} + +async function loadEditPositions() { + const params: any = { page: 1, pageSize: 200 } + if (editForm.value.countryId) params.countryId = editForm.value.countryId + if (editForm.value.categoryId) params.categoryId = editForm.value.categoryId + try { + const res = await positionsApi.getPositionsList(params) as any + configPositions.value = Array.isArray(res) ? res : (res.items ?? []) + } catch { configPositions.value = [] } } async function handleDeleteGood(g: Good) { @@ -1264,6 +1479,7 @@ onMounted(() => loadAll()) 搜索 + 新增自定义商品
@@ -1343,7 +1559,7 @@ onMounted(() => loadAll())
-
原产品
+
{{ data.isCustom ? '来源' : '原产品' }}
{{ data.originGoodName }}
@@ -1353,13 +1569,14 @@ onMounted(() => loadAll()) - +
下架 + 自定义 {{ data.country }} loadAll()) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
关联原产品
+
{{ editIsCustom ? '自定义商品' : '关联原产品' }}
{{ editGood.originGood.goodName }}
-
SDS ID: {{ editGood.originGood.sdsGoodId }}
+
{{ editIsCustom ? '自定义 ID' : 'SDS ID' }}: {{ editGood.originGood.sdsGoodId }}
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }} @@ -1601,6 +1858,7 @@ onMounted(() => loadAll())
loadAll())
- + - +
- +
@@ -1632,14 +1890,43 @@ onMounted(() => loadAll()) - +
+ + + + + + + + - + + + + 小时 + + + + + + + + + + + + + + + + + + {{ editOriginDetail.productCode || '-' }} {{ editOriginDetail.englishName || '-' }} {{ editOriginDetail.productionCycleHours ?? '-' }} 小时 @@ -1649,7 +1936,8 @@ onMounted(() => loadAll()) - + + loadAll()) - + +