From 3647bebf1926d07a7bd651c109426ecdca276132 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 21 Feb 2026 17:25:47 +0800 Subject: [PATCH] feat(project): align product list/detail with prototype --- apps/web-antd/src/api/product/index.ts | 2 + apps/web-antd/src/mock/index.ts | 17 +- .../composables/useProductDetailPage.ts | 250 ++++++++ .../src/views/product/detail/index.vue | 561 ++++++++++-------- .../views/product/detail/styles/index.less | 209 +++++-- .../src/views/product/detail/types.ts | 23 + .../list/components/ProductFilterToolbar.vue | 31 +- .../list/components/ProductListSection.vue | 35 +- .../composables/product-list-page/helpers.ts | 4 +- .../web-antd/src/views/product/list/index.vue | 4 +- .../views/product/list/styles/toolbar.less | 13 +- 11 files changed, 814 insertions(+), 335 deletions(-) create mode 100644 apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts create mode 100644 apps/web-antd/src/views/product/detail/types.ts diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 32aba40..6c0e33e 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -143,6 +143,7 @@ export interface ProductListItemDto { /** 商品详情。 */ export interface ProductDetailDto extends ProductListItemDto { description: string; + imageUrls?: string[]; notifyManager: boolean; recoverAt: null | string; remainStock: number; @@ -172,6 +173,7 @@ export interface SaveProductDto { categoryId: string; description: string; id?: string; + imageUrls?: string[]; kind: ProductKind; name: string; originalPrice: null | number; diff --git a/apps/web-antd/src/mock/index.ts b/apps/web-antd/src/mock/index.ts index 4310e67..1ceb702 100644 --- a/apps/web-antd/src/mock/index.ts +++ b/apps/web-antd/src/mock/index.ts @@ -1,8 +1,11 @@ -// Mock 数据入口,仅在开发环境下使用 -// 门店模块与商品分类管理已切换真实 TenantApi,此处仅保留其他业务 mock。 -import './product'; -import './product-extensions'; +// Mock 数据入口,仅在开发环境下使用。 +// 默认关闭商品相关 mock;需要时可通过环境变量显式开启。 +if (import.meta.env.VITE_MOCK_PRODUCT === 'true') { + void import('./product'); + console.warn('[Mock] 已启用商品列表 Mock 数据'); +} -console.warn( - '[Mock] 已启用非门店/非分类管理 Mock 数据(分类管理强制走真实 API)', -); +if (import.meta.env.VITE_MOCK_PRODUCT_EXTENSIONS === 'true') { + void import('./product-extensions'); + console.warn('[Mock] 已启用商品扩展模块 Mock 数据'); +} diff --git a/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts new file mode 100644 index 0000000..8507eda --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts @@ -0,0 +1,250 @@ +import type { ProductDetailFormState } from '../types'; + +import type { ProductDetailDto, ProductStatus } from '#/api/product'; + +import { computed, reactive, ref, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; + +import { message } from 'ant-design-vue'; + +import { uploadTenantFileApi } from '#/api/files'; +import { + deleteProductApi, + getProductCategoryListApi, + getProductDetailApi, + saveProductApi, +} from '#/api/product'; +import { + tagsToText, + textToTags, +} from '#/views/product/list/composables/product-list-page/helpers'; + +const DEFAULT_FORM: ProductDetailFormState = { + id: '', + name: '', + subtitle: '', + categoryId: '', + kind: 'single', + description: '', + price: 0, + originalPrice: null, + stock: 0, + status: 'off_shelf', + tagsText: '', + imageUrls: [], + shelfMode: 'draft', + timedOnShelfAt: '', +}; + +export function useProductDetailPage() { + const route = useRoute(); + const router = useRouter(); + + const isLoading = ref(false); + const isSubmitting = ref(false); + const isUploadingImage = ref(false); + const detail = ref(null); + const categoryOptions = ref>([]); + const form = reactive({ ...DEFAULT_FORM }); + + const storeId = computed(() => String(route.query.storeId || '')); + const productId = computed(() => String(route.query.productId || '')); + + const statusText = computed(() => { + if (form.status === 'on_sale') return '在售'; + if (form.status === 'sold_out') return '售罄'; + return '下架'; + }); + + const statusColor = computed(() => { + if (form.status === 'on_sale') return '#52c41a'; + if (form.status === 'sold_out') return '#ef4444'; + return '#9ca3af'; + }); + + /** 回填编辑表单。 */ + function patchForm(data: ProductDetailDto) { + form.id = data.id; + form.name = data.name; + form.subtitle = data.subtitle; + form.categoryId = data.categoryId; + form.kind = data.kind; + form.description = data.description; + form.price = data.price; + form.originalPrice = data.originalPrice; + form.stock = data.stock; + form.status = data.status; + form.tagsText = tagsToText(data.tags || []); + form.imageUrls = [...(data.imageUrls || []), data.imageUrl] + .map((item) => String(item || '').trim()) + .filter(Boolean) + .filter((item, index, source) => source.indexOf(item) === index) + .slice(0, 5); + form.shelfMode = data.status === 'on_sale' ? 'now' : 'draft'; + form.timedOnShelfAt = ''; + } + + /** 解析门店+商品并拉取详情。 */ + async function loadDetail() { + if (!storeId.value || !productId.value) { + detail.value = null; + categoryOptions.value = []; + return; + } + + isLoading.value = true; + try { + const [detailData, categories] = await Promise.all([ + getProductDetailApi({ + storeId: storeId.value, + productId: productId.value, + }), + getProductCategoryListApi(storeId.value), + ]); + + detail.value = detailData; + categoryOptions.value = categories.map((item) => ({ + label: item.name, + value: item.id, + })); + patchForm(detailData); + } catch (error) { + console.error(error); + detail.value = null; + categoryOptions.value = []; + } finally { + isLoading.value = false; + } + } + + /** 上传图片并追加到图集。 */ + async function uploadImage(file: File) { + isUploadingImage.value = true; + try { + const uploaded = await uploadTenantFileApi(file, 'dish_image'); + const url = String(uploaded.url || '').trim(); + if (!url) { + message.error('图片上传失败'); + return; + } + form.imageUrls = [...form.imageUrls, url] + .filter((item, index, source) => source.indexOf(item) === index) + .slice(0, 5); + message.success('图片上传成功'); + } catch (error) { + console.error(error); + } finally { + isUploadingImage.value = false; + } + } + + /** 设置主图。 */ + function setPrimaryImage(index: number) { + if (index <= 0 || index >= form.imageUrls.length) return; + const current = [...form.imageUrls]; + const [selected] = current.splice(index, 1); + if (!selected) return; + form.imageUrls = [selected, ...current]; + } + + /** 删除图片。 */ + function removeImage(index: number) { + form.imageUrls = form.imageUrls.filter((_, i) => i !== index); + } + + /** 保存详情。 */ + async function saveDetail() { + if (!storeId.value) return; + if (!form.id) return; + if (!form.name.trim()) { + message.warning('请输入商品名称'); + return; + } + if (!form.categoryId) { + message.warning('请选择商品分类'); + return; + } + if (form.shelfMode === 'scheduled' && !form.timedOnShelfAt) { + message.warning('请选择定时上架时间'); + return; + } + + isSubmitting.value = true; + try { + const saved = await saveProductApi({ + id: form.id, + storeId: storeId.value, + categoryId: form.categoryId, + kind: form.kind, + name: form.name.trim(), + subtitle: form.subtitle.trim(), + description: form.description.trim(), + price: Number(form.price || 0), + originalPrice: + form.originalPrice && Number(form.originalPrice) > 0 + ? Number(form.originalPrice) + : null, + stock: Math.max(0, Math.floor(Number(form.stock || 0))), + tags: textToTags(form.tagsText), + status: form.status, + shelfMode: form.shelfMode, + timedOnShelfAt: + form.shelfMode === 'scheduled' ? form.timedOnShelfAt : undefined, + imageUrls: [...form.imageUrls], + }); + detail.value = saved; + patchForm(saved); + message.success('商品详情已保存'); + } catch (error) { + console.error(error); + } finally { + isSubmitting.value = false; + } + } + + /** 切换在售/下架。 */ + async function toggleSaleStatus(next: ProductStatus) { + if (next !== 'on_sale' && next !== 'off_shelf') return; + form.status = next; + form.shelfMode = next === 'on_sale' ? 'now' : 'draft'; + await saveDetail(); + } + + /** 删除当前商品并返回列表。 */ + async function deleteCurrentProduct() { + if (!storeId.value || !form.id) return; + await deleteProductApi({ + storeId: storeId.value, + productId: form.id, + }); + message.success('商品已删除'); + router.push('/product/list'); + } + + /** 返回列表页。 */ + function goBack() { + router.push('/product/list'); + } + + watch([storeId, productId], loadDetail, { immediate: true }); + + return { + categoryOptions, + deleteCurrentProduct, + detail, + form, + goBack, + isLoading, + isSubmitting, + isUploadingImage, + loadDetail, + removeImage, + saveDetail, + setPrimaryImage, + statusColor, + statusText, + storeId, + toggleSaleStatus, + uploadImage, + }; +} diff --git a/apps/web-antd/src/views/product/detail/index.vue b/apps/web-antd/src/views/product/detail/index.vue index 1e2237e..e13f6f9 100644 --- a/apps/web-antd/src/views/product/detail/index.vue +++ b/apps/web-antd/src/views/product/detail/index.vue @@ -1,13 +1,15 @@