| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- import { View, Text, ScrollView, Input } from '@tarojs/components'
- import { Button } from '@/components/ui/button'
- import { useState, useEffect } from 'react'
- import { goodsClient } from '@/api'
- import Taro from '@tarojs/taro'
- import './index.css'
- interface SpecOption {
- id: number
- name: string
- price: number
- stock: number
- image?: string
- }
- interface GoodsFromApi {
- id: number
- name: string
- price: number
- stock: number
- imageFile?: { fullUrl: string }
- }
- interface SpecSelectorProps {
- visible: boolean
- onClose: () => void
- onConfirm: (selectedSpec: SpecOption | null, quantity: number, actionType?: 'add-to-cart' | 'buy-now') => void
- parentGoodsId: number
- currentSpec?: string
- currentQuantity?: number
- actionType?: 'add-to-cart' | 'buy-now'
- }
- export function GoodsSpecSelector({
- visible,
- onClose,
- onConfirm,
- parentGoodsId,
- currentSpec,
- currentQuantity = 1,
- actionType
- }: SpecSelectorProps) {
- const [selectedSpec, setSelectedSpec] = useState<SpecOption | null>(null)
- const [quantity, setQuantity] = useState(currentQuantity)
- const [specOptions, setSpecOptions] = useState<SpecOption[]>([])
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- // 从API获取子商品数据作为规格选项
- useEffect(() => {
- // 重置状态
- setSpecOptions([])
- setSelectedSpec(null)
- setError(null)
- if (visible && parentGoodsId > 0) {
- // 调用真实的子商品列表API
- const fetchChildGoods = async () => {
- setIsLoading(true)
- setError(null)
- try {
- const response = await goodsClient[':id'].children.$get({
- param: { id: parentGoodsId },
- query: {
- page: 1,
- pageSize: 100, // 获取所有子商品,假设不会超过100个
- sortBy: 'createdAt',
- sortOrder: 'ASC'
- }
- })
- if (response.status === 200) {
- const data = await response.json()
- // 将子商品数据转换为规格选项格式
- const childGoodsAsSpecs: SpecOption[] = data.data.map((goods: GoodsFromApi) => ({
- id: goods.id, // 子商品ID
- name: goods.name, // 子商品名称作为规格名称
- price: goods.price,
- stock: goods.stock,
- image: goods.imageFile?.fullUrl
- }))
- setSpecOptions(childGoodsAsSpecs)
- // 如果有当前选中的规格,设置选中状态
- if (currentSpec) {
- const foundSpec = childGoodsAsSpecs.find(spec => spec.name === currentSpec)
- if (foundSpec) {
- setSelectedSpec(foundSpec)
- }
- }
- } else {
- // 尝试解析响应体获取具体错误消息
- let errorMsg = `获取子商品列表失败: ${response.status}`
- try {
- const errorData = await response.json()
- if (errorData && errorData.message) {
- errorMsg = errorData.message
- }
- } catch (jsonError) {
- console.warn('无法解析错误响应体:', jsonError)
- }
- console.error('获取子商品列表失败:', { status: response.status, message: errorMsg })
- setError(errorMsg)
- setSpecOptions([])
- }
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : '获取子商品列表异常'
- console.error('获取子商品列表异常:', error)
- setError(errorMsg)
- setSpecOptions([])
- } finally {
- setIsLoading(false)
- }
- }
- fetchChildGoods()
- } else {
- // 如果不可见或parentGoodsId无效,清空规格选项
- setIsLoading(false)
- }
- }, [visible, parentGoodsId, currentSpec])
- const handleSpecSelect = (spec: SpecOption) => {
- setSelectedSpec(spec)
- // 重置数量为1
- setQuantity(1)
- }
- // 计算总价
- const calculateTotalPrice = () => {
- if (!selectedSpec) return 0
- return selectedSpec.price * quantity
- }
- // 验证价格计算正确性
- const validatePriceCalculation = () => {
- if (selectedSpec && quantity > 0) {
- const calculatedPrice = calculateTotalPrice()
- const expectedPrice = selectedSpec.price * quantity
- if (calculatedPrice !== expectedPrice) {
- console.error('价格计算错误:', { calculatedPrice, expectedPrice, specPrice: selectedSpec.price, quantity })
- }
- }
- }
- // 在数量或规格变化时验证价格计算
- useEffect(() => {
- validatePriceCalculation()
- }, [selectedSpec, quantity])
- // 获取最大可购买数量
- const getMaxQuantity = () => {
- if (!selectedSpec) return 999
- return Math.min(selectedSpec.stock, 999)
- }
- // 处理减少数量
- const handleDecrease = () => {
- if (!selectedSpec) return
- const currentQty = quantity === 0 ? 1 : quantity
- const newQuantity = Math.max(1, currentQty - 1)
- setQuantity(newQuantity)
- }
- // 处理增加数量
- const handleIncrease = () => {
- if (!selectedSpec) return
- const currentQty = quantity === 0 ? 1 : quantity
- const maxQuantity = getMaxQuantity()
- if (currentQty >= maxQuantity) {
- if (maxQuantity === selectedSpec.stock) {
- Taro.showToast({
- title: `库存只有${selectedSpec.stock}件`,
- icon: 'none',
- duration: 1500
- })
- } else {
- Taro.showToast({
- title: '单次最多购买999件',
- icon: 'none',
- duration: 1500
- })
- }
- return
- }
- setQuantity(currentQty + 1)
- }
- // 处理数量输入变化
- const handleQuantityChange = (value: string) => {
- if (!selectedSpec) return
- // 清除非数字字符
- const cleanedValue = value.replace(/[^\d]/g, '')
- // 如果输入为空,设为空字符串(允许用户删除)
- if (cleanedValue === '') {
- setQuantity(0) // 设为0表示空输入
- return
- }
- const numValue = parseInt(cleanedValue)
- // 验证最小值
- if (numValue < 1) {
- setQuantity(1)
- Taro.showToast({
- title: '数量不能小于1',
- icon: 'none',
- duration: 1500
- })
- return
- }
- // 验证最大值
- const maxQuantity = getMaxQuantity()
- if (numValue > maxQuantity) {
- setQuantity(maxQuantity)
- if (maxQuantity === selectedSpec.stock) {
- Taro.showToast({
- title: `库存只有${selectedSpec.stock}件`,
- icon: 'none',
- duration: 1500
- })
- } else {
- Taro.showToast({
- title: '单次最多购买999件',
- icon: 'none',
- duration: 1500
- })
- }
- return
- }
- setQuantity(numValue)
- }
- // 处理输入框失去焦点(完成输入)
- const handleQuantityBlur = () => {
- // 如果数量小于1(表示空输入或负数),设为1
- if (quantity < 1) {
- setQuantity(1)
- }
- }
- const getConfirmButtonText = (spec: SpecOption, qty: number, action?: 'add-to-cart' | 'buy-now') => {
- const totalPrice = spec.price * qty
- const priceText = `¥${totalPrice.toFixed(2)}`
- if (action === 'add-to-cart') {
- return `加入购物车 (${priceText})`
- } else if (action === 'buy-now') {
- return `立即购买 (${priceText})`
- } else {
- return `确定 (${priceText})`
- }
- }
- const handleConfirm = () => {
- if (!selectedSpec) {
- // 提示用户选择规格
- return
- }
- onConfirm(selectedSpec, quantity, actionType)
- onClose()
- }
- if (!visible) return null
- return (
- <View className="spec-modal">
- <View className="spec-modal-content">
- <View className="spec-modal-header">
- <Text className="spec-modal-title">选择规格</Text>
- <View
- className="spec-modal-close"
- onClick={onClose}
- >
- <View className="i-heroicons-x-mark-20-solid" />
- </View>
- </View>
- <ScrollView className="spec-options" scrollY>
- {isLoading ? (
- <View className="spec-loading">
- <View className="i-heroicons-arrow-path-20-solid animate-spin w-8 h-8 text-blue-500" />
- <Text className="loading-text">加载规格选项...</Text>
- </View>
- ) : error ? (
- <View className="spec-error">
- <View className="i-heroicons-exclamation-triangle-20-solid w-8 h-8 text-red-500" />
- <Text className="error-text">{error}</Text>
- <Button
- size="sm"
- variant="outline"
- className="retry-btn"
- onClick={() => {
- // 重新触发数据获取
- setSpecOptions([])
- setSelectedSpec(null)
- setError(null)
- setIsLoading(true)
- // 这里应该重新调用API,但useEffect会基于依赖项自动重新执行
- }}
- >
- 重试
- </Button>
- </View>
- ) : specOptions.length === 0 ? (
- <View className="spec-empty">
- <View className="i-heroicons-information-circle-20-solid w-8 h-8 text-gray-400" />
- <Text className="empty-text">暂无规格选项</Text>
- </View>
- ) : (
- specOptions.map(spec => (
- <View
- key={spec.id}
- className={`spec-option ${selectedSpec?.id === spec.id ? 'selected' : ''}`}
- onClick={() => handleSpecSelect(spec)}
- >
- <Text className="spec-option-text">{spec.name}</Text>
- <View className="spec-option-price">
- <Text className="price-text">¥{spec.price.toFixed(2)}</Text>
- <Text className="stock-text">库存: {spec.stock}</Text>
- </View>
- </View>
- ))
- )}
- </ScrollView>
- {/* 数量选择器 */}
- {selectedSpec && (
- <View className="quantity-section">
- <Text className="quantity-label">数量</Text>
- <View className="quantity-controls">
- <Button
- size="sm"
- variant="ghost"
- className="quantity-btn"
- onClick={handleDecrease}
- >
- -
- </Button>
- <Input
- className="quantity-input"
- type="number"
- value={quantity === 0 ? '' : quantity.toString()}
- onInput={(e) => handleQuantityChange(e.detail.value)}
- onBlur={handleQuantityBlur}
- placeholder="1"
- maxlength={3}
- confirmType="done"
- />
- <Button
- size="sm"
- variant="ghost"
- className="quantity-btn"
- onClick={handleIncrease}
- >
- +
- </Button>
- </View>
- </View>
- )}
- <View className="spec-modal-footer">
- <Button
- className="spec-confirm-btn"
- onClick={handleConfirm}
- disabled={!selectedSpec}
- >
- {selectedSpec ? getConfirmButtonText(selectedSpec, quantity, actionType) : '请选择规格'}
- </Button>
- </View>
- </View>
- </View>
- )
- }
|