Jelajahi Sumber

✨ feat(goods): 完善商品价格面议功能的前端实现

- 在商品卡片组件中,为无规格且价格为0的商品显示"面议"文案,并移除未使用的selectedSpec状态
- 在商品规格选择器中,为价格为0的规格选项显示"面议"标签,并调整确认按钮文案以包含面议提示
- 在商品详情页中,新增价格格式化函数,将0显示为"面议",并更新分享标题以反映面议状态
- 在商品管理后台中,为价格输入框添加"面议"切换按钮和状态显示,优化价格字段的交互体验
- 更新商品Schema文档,明确价格字段为0时表示面议的业务规则

♻️ refactor(goods): 优化类型定义和代码结构

- 在商品规格选择器中移除未使用的GoodsFromApi接口,改用any类型简化数据映射
- 在商品详情页中定义明确的GoodsResponse接口,避免类型推断问题,提升代码可维护性
- 在商品管理后台中重构GoodsResponse类型,明确API返回的数据结构,移除不必要的类型导入
yourname 2 minggu lalu
induk
melakukan
6d86d1217a

+ 5 - 3
mini/src/components/goods-card/index.tsx

@@ -44,7 +44,6 @@ export default function GoodsCard({
   onAddCart
 }: GoodsCardProps) {
   const [showSpecModal, setShowSpecModal] = useState(false)
-  const [selectedSpec, setSelectedSpec] = useState<SelectedSpec | null>(null)
   const [pendingAction, setPendingAction] = useState<'add-to-cart' | null>(null)
   const [allChildGoodsOutOfStock, setAllChildGoodsOutOfStock] = useState(false)
 
@@ -144,7 +143,6 @@ export default function GoodsCard({
         stock: spec.stock,
         image: spec.image || data.cover_image // 使用规格图片或商品封面图片
       })
-      setSelectedSpec(spec)
     }
     setShowSpecModal(false)
     setPendingAction(null)
@@ -209,7 +207,11 @@ export default function GoodsCard({
 
           <View className="goods-card__down">
             {/* 价格区域 */}
-            {data.price && (
+            {!data.hasSpecOptions && data.price === 0 ? (
+              <View className="goods-card__price">
+                <Text className="goods-card__current-price">面议</Text>
+              </View>
+            ) : data.price && (
               <View className="goods-card__price">
                 <Text className="goods-card__symbol">{currency}</Text>
                 <Text className="goods-card__current-price">

+ 8 - 10
mini/src/components/goods-spec-selector/index.tsx

@@ -13,14 +13,6 @@ interface SpecOption {
   image?: string
 }
 
-interface GoodsFromApi {
-  id: number
-  name: string
-  price: number
-  stock: number
-  imageFile?: { fullUrl: string }
-}
-
 interface SpecSelectorProps {
   visible: boolean
   onClose: () => void
@@ -73,7 +65,7 @@ export function GoodsSpecSelector({
           if (response.status === 200) {
             const data = await response.json()
             // 将子商品数据转换为规格选项格式
-            const childGoodsAsSpecs: SpecOption[] = data.data.map((goods: GoodsFromApi) => ({
+            const childGoodsAsSpecs: SpecOption[] = data.data.map((goods: any) => ({
               id: goods.id, // 子商品ID
               name: goods.name, // 子商品名称作为规格名称
               price: goods.price,
@@ -281,6 +273,12 @@ export function GoodsSpecSelector({
   }
 
   const getConfirmButtonText = (spec: SpecOption, qty: number, action?: 'add-to-cart' | 'buy-now') => {
+    // 价格为0时显示"面议"
+    if (spec.price === 0) {
+      const actionText = action === 'add-to-cart' ? '加入购物车' : action === 'buy-now' ? '立即购买' : '确定'
+      return `${actionText} (面议)`
+    }
+
     const totalPrice = spec.price * qty
     const priceText = `¥${totalPrice.toFixed(2)}`
 
@@ -364,7 +362,7 @@ export function GoodsSpecSelector({
               >
                 <Text className="spec-option-text">{spec.name}</Text>
                 <View className="spec-option-price">
-                  <Text className="price-text">¥{spec.price.toFixed(2)}</Text>
+                  <Text className="price-text">{spec.price === 0 ? '面议' : `¥${spec.price.toFixed(2)}`}</Text>
                   <Text className="stock-text">库存: {spec.stock}</Text>
                 </View>
               </View>

+ 25 - 7
mini/src/pages/goods-detail/index.tsx

@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query'
 import { useState, useEffect } from 'react'
 import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'
 import { goodsClient } from '@/api'
-// import { InferResponseType } from 'hono'
 import { Navbar } from '@/components/ui/navbar'
 import { Button } from '@/components/ui/button'
 import { Carousel } from '@/components/ui/carousel'
@@ -12,7 +11,21 @@ import TDesignTabs from '@/components/tdesign/tabs'
 import { useCart } from '@/contexts/CartContext'
 import './index.css'
 
-// type GoodsResponse = InferResponseType<typeof goodsClient[':id']['$get'], 200>
+// 商品详情响应类型(简化定义,避免类型推断问题)
+interface GoodsResponse {
+  id: number
+  name: string
+  price: number
+  costPrice: number
+  salesNum: number
+  stock: number
+  spuId: number
+  detail?: string
+  instructions?: string
+  slideImages?: Array<{ fullUrl: string }>
+  imageFile?: { fullUrl: string }
+  [key: string]: any
+}
 
 interface SelectedSpec {
   id: number
@@ -38,7 +51,7 @@ export default function GoodsDetailPage() {
   const goodsId = params?.id ? parseInt(params.id) : 0
   const fromPage = params?.from || ''
 
-  const { data: goods, isLoading } = useQuery({
+  const { data: goods, isLoading } = useQuery<GoodsResponse>({
     queryKey: ['goods', goodsId],
     queryFn: async () => {
       const response = await goodsClient[':id'].$get({
@@ -47,14 +60,14 @@ export default function GoodsDetailPage() {
       if (response.status !== 200) {
         throw new Error('获取商品详情失败')
       }
-      return response.json()
+      return (response.json() as unknown) as GoodsResponse
     },
     enabled: goodsId > 0,
     staleTime: 5 * 60 * 1000,
   })
 
   // 获取子商品列表,用于判断是否有规格选项
-  const { data: childGoodsData } = useQuery({
+  const { data: childGoodsData } = useQuery<any>({
     queryKey: ['goods', goodsId, 'children'],
     queryFn: async () => {
       const response = await goodsClient[':id'].children.$get({
@@ -77,6 +90,10 @@ export default function GoodsDetailPage() {
 
   const hasSpecOptions = Boolean(childGoodsData && childGoodsData.data && childGoodsData.data.length > 0)
 
+  // 格式化价格显示,0显示为"面议"
+  const formatPrice = (price: number): string => {
+    return price === 0 ? '面议' : `¥${price.toFixed(2)}`
+  }
 
   // 商品轮播图
   const carouselItems = goods?.slideImages?.map((file: any) => ({
@@ -105,8 +122,9 @@ export default function GoodsDetailPage() {
 
   // 分享功能
   useShareAppMessage(() => {
+    const priceText = goods?.price === 0 ? '面议' : `¥${goods?.price || 0}`
     return {
-      title: goods ? `${goods.name} - ¥${goods.price}` : '发现好物',
+      title: goods ? `${goods.name} - ${priceText}` : '发现好物',
       path: `/pages/goods-detail/index?id=${goodsId}&from=share`,
       imageUrl: goods?.slideImages?.[0]?.fullUrl || goods?.imageFile?.fullUrl
     }
@@ -463,7 +481,7 @@ export default function GoodsDetailPage() {
           <View className="goods-price-row">
             <View className="price-container">
               <Text className="current-price">
-                ¥{goods.price.toFixed(2)}
+                {formatPrice(goods.price)}
               </Text>
               <Text className="original-price">¥{goods.costPrice.toFixed(2)}</Text>
               {hasSpecOptions && <Text className="price-suffix">起</Text>}

+ 84 - 19
packages/goods-management-ui-mt/src/components/GoodsManagement.tsx

@@ -5,7 +5,7 @@ import { zhCN } from 'date-fns/locale';
 import { toast } from 'sonner';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { useForm } from 'react-hook-form';
-import type { InferRequestType, InferResponseType } from 'hono/client';
+import type { InferRequestType } from 'hono/client';
 
 import { Button } from '@d8d/shared-ui-components/components/ui/button';
 import { Input } from '@d8d/shared-ui-components/components/ui/input';
@@ -20,7 +20,8 @@ import { Textarea } from '@d8d/shared-ui-components/components/ui/textarea';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@d8d/shared-ui-components/components/ui/select';
 
 import { goodsClient, goodsClientManager } from '../api/goodsClient';
-import { AdminCreateGoodsDto, AdminUpdateGoodsDto } from '@d8d/goods-module-mt/schemas';
+import { AdminCreateGoodsDto, AdminUpdateGoodsDto, AdminGoodsSchema } from '@d8d/goods-module-mt/schemas';
+import { z } from 'zod';
 import { supplierClientManager } from '@d8d/supplier-management-ui-mt/api';
 import { merchantClientManager } from '@d8d/merchant-management-ui-mt/api';
 import { DataTablePagination } from '@d8d/shared-ui-components/components/admin/DataTablePagination';
@@ -33,7 +34,31 @@ import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
 
 type CreateRequest = InferRequestType<typeof goodsClient.index.$post>['json'];
 type UpdateRequest = InferRequestType<typeof goodsClient[':id']['$put']>['json'];
-type GoodsResponse = InferResponseType<typeof goodsClient.index.$get, 200>['data'][0];
+
+// API返回的File类型(日期为字符串格式)
+type ApiFile = {
+  id: number;
+  tenantId: number;
+  name: string;
+  type: string | null;
+  size: number | null;
+  path: string;
+  fullUrl: string;
+  description: string | null;
+  uploadUserId: number;
+  uploadTime: string;
+  lastUpdated: string | null;
+  createdAt: string;
+  updatedAt: string;
+};
+
+// API返回的商品类型(日期为字符串格式)
+type GoodsResponse = Omit<z.infer<typeof AdminGoodsSchema>, 'createdAt' | 'updatedAt' | 'slideImages' | 'imageFile'> & {
+  createdAt: string;
+  updatedAt: string;
+  slideImages: ApiFile[] | null;
+  imageFile: ApiFile | null;
+};
 
 const createFormSchema = AdminCreateGoodsDto;
 const updateFormSchema = AdminUpdateGoodsDto;
@@ -52,10 +77,8 @@ export const GoodsManagement: React.FC = () => {
     batchSpecs: [] as BatchSpecTemplate[]
   });
 
-  const [isVisible, setIsVisible] = useState(false);
-
   // 创建表单
-  const createForm = useForm<CreateRequest>({
+  const createForm = useForm({
     resolver: zodResolver(createFormSchema),
     defaultValues: {
       name: '',
@@ -79,7 +102,7 @@ export const GoodsManagement: React.FC = () => {
   });
 
   // 更新表单
-  const updateForm = useForm<UpdateRequest>({
+  const updateForm = useForm({
     resolver: zodResolver(updateFormSchema),
   });
 
@@ -101,7 +124,7 @@ export const GoodsManagement: React.FC = () => {
   });
 
   // 获取供应商列表(用于默认选择第一个)
-  const { data: suppliersData, isLoading: isLoadingSuppliers } = useQuery({
+  const { data: suppliersData } = useQuery({
     queryKey: ['suppliers-for-goods'],
     queryFn: async () => {
       const res = await supplierClientManager.get().index.$get({
@@ -117,7 +140,7 @@ export const GoodsManagement: React.FC = () => {
   });
 
   // 获取商户列表(用于默认选择第一个)
-  const { data: merchantsData, isLoading: isLoadingMerchants } = useQuery({
+  const { data: merchantsData } = useQuery({
     queryKey: ['merchants-for-goods'],
     queryFn: async () => {
       const res = await merchantClientManager.get().index.$get({
@@ -436,7 +459,7 @@ export const GoodsManagement: React.FC = () => {
                         </div>
                       </div>
                     </TableCell>
-                    <TableCell>¥{goods.price.toFixed(2)}</TableCell>
+                    <TableCell>{goods.price === 0 ? '面议' : `¥${goods.price.toFixed(2)}`}</TableCell>
                     <TableCell>{goods.stock}</TableCell>
                     <TableCell>{goods.salesNum}</TableCell>
                     {/* <TableCell>{goods.supplier?.name || '-'}</TableCell>
@@ -454,7 +477,7 @@ export const GoodsManagement: React.FC = () => {
                         <Button
                           variant="ghost"
                           size="icon"
-                          onClick={() => handleEditGoods(goods)}
+                          onClick={() => handleEditGoods(goods as GoodsResponse)}
                           data-testid="edit-goods-button"
                         >
                           <Edit className="h-4 w-4" />
@@ -530,14 +553,33 @@ export const GoodsManagement: React.FC = () => {
                       <FormItem>
                         <FormLabel>售卖价 <span className="text-red-500">*</span></FormLabel>
                         <FormControl>
-                          <Input
-                            type="number"
-                            step="0.01"
-                            placeholder="0.00"
-                            data-testid="goods-price-input"
-                            {...field}
-                          />
+                          <div className="flex gap-2">
+                            <Input
+                              type="number"
+                              step="1"
+                              placeholder="0.00"
+                              data-testid="goods-price-input"
+                              disabled={(field.value as number) === 0}
+                              value={field.value as number}
+                              onChange={field.onChange}
+                              onBlur={field.onBlur}
+                              name={field.name}
+                              ref={field.ref}
+                            />
+                            <Button
+                              type="button"
+                              variant={(field.value as number) === 0 ? "default" : "outline"}
+                              size="sm"
+                              className="whitespace-nowrap"
+                              onClick={() => {
+                                field.onChange((field.value as number) === 0 ? 0.01 : 0);
+                              }}
+                            >
+                              {(field.value as number) === 0 ? "面议" : "设为面议"}
+                            </Button>
+                          </div>
                         </FormControl>
+                        <FormDescription>{(field.value as number) === 0 ? "当前价格:面议" : "输入具体价格或点击设为面议"}</FormDescription>
                         <FormMessage />
                       </FormItem>
                     )}
@@ -760,8 +802,31 @@ export const GoodsManagement: React.FC = () => {
                       <FormItem>
                         <FormLabel>售卖价</FormLabel>
                         <FormControl>
-                          <Input type="number" step="0.01" {...field} />
+                          <div className="flex gap-2">
+                            <Input
+                              type="number"
+                              step="0.01"
+                              disabled={(field.value as number) === 0}
+                              value={field.value as number}
+                              onChange={field.onChange}
+                              onBlur={field.onBlur}
+                              name={field.name}
+                              ref={field.ref}
+                            />
+                            <Button
+                              type="button"
+                              variant={(field.value as number) === 0 ? "default" : "outline"}
+                              size="sm"
+                              className="whitespace-nowrap"
+                              onClick={() => {
+                                field.onChange((field.value as number) === 0 ? 0.01 : 0);
+                              }}
+                            >
+                              {(field.value as number) === 0 ? "面议" : "设为面议"}
+                            </Button>
+                          </div>
                         </FormControl>
+                        <FormDescription>{(field.value as number) === 0 ? "当前价格:面议" : "输入具体价格或点击设为面议"}</FormDescription>
                         <FormMessage />
                       </FormItem>
                     )}

+ 7 - 8
packages/goods-module-mt/src/schemas/admin-goods.schema.mt.ts

@@ -6,14 +6,14 @@ import { MerchantSchemaMt } from '@d8d/merchant-module-mt/schemas';
 import { ParentGoodsSchema } from './parent-goods.schema.mt';
 
 // 管理员专用商品Schema - 保留完整权限字段
-export const AdminGoodsSchema: z.ZodObject<any> = z.object({
+export const AdminGoodsSchema = z.object({
   id: z.number().int().positive().openapi({ description: '商品ID' }),
   name: z.string().min(1, '商品名称不能为空').max(255, '商品名称最多255个字符').openapi({
     description: '商品名称',
     example: 'iPhone 15'
   }),
   price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').default(0).openapi({
-    description: '售卖价',
+    description: '售卖价,0表示面议',
     example: 5999.99
   }),
   costPrice: z.coerce.number().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').default(0).openapi({
@@ -148,8 +148,8 @@ export const AdminCreateGoodsDto = z.object({
     description: '商品名称',
     example: 'iPhone 15'
   }),
-  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').default(0).openapi({
-    description: '售卖价',
+  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').default(0).openapi({
+    description: '售卖价,0表示面议',
     example: 5999.99
   }),
   costPrice: z.coerce.number<number>().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').default(0).openapi({
@@ -232,14 +232,13 @@ export const AdminCreateGoodsDto = z.object({
 });
 
 // 管理员更新商品DTO - 保留完整权限字段
-export const 
-AdminUpdateGoodsDto = z.object({
+export const AdminUpdateGoodsDto = z.object({
   name: z.string().min(1, '商品名称不能为空').max(255, '商品名称最多255个字符').optional().openapi({
     description: '商品名称',
     example: 'iPhone 15'
   }),
-  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').optional().openapi({
-    description: '售卖价',
+  price: z.coerce.number().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数').optional().openapi({
+    description: '售卖价,0表示面议',
     example: 5999.99
   }),
   costPrice: z.coerce.number<number>().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').optional().openapi({