index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import { View, Text, ScrollView, Input } from '@tarojs/components'
  2. import { Button } from '@/components/ui/button'
  3. import { useState, useEffect } from 'react'
  4. import { goodsClient } from '@/api'
  5. import Taro from '@tarojs/taro'
  6. import './index.css'
  7. interface SpecOption {
  8. id: number
  9. name: string
  10. price: number
  11. stock: number
  12. image?: string
  13. }
  14. interface GoodsFromApi {
  15. id: number
  16. name: string
  17. price: number
  18. stock: number
  19. imageFile?: { fullUrl: string }
  20. }
  21. interface SpecSelectorProps {
  22. visible: boolean
  23. onClose: () => void
  24. onConfirm: (selectedSpec: SpecOption | null, quantity: number, actionType?: 'add-to-cart' | 'buy-now') => void
  25. parentGoodsId: number
  26. currentSpec?: string
  27. currentQuantity?: number
  28. actionType?: 'add-to-cart' | 'buy-now'
  29. }
  30. export function GoodsSpecSelector({
  31. visible,
  32. onClose,
  33. onConfirm,
  34. parentGoodsId,
  35. currentSpec,
  36. currentQuantity = 1,
  37. actionType
  38. }: SpecSelectorProps) {
  39. const [selectedSpec, setSelectedSpec] = useState<SpecOption | null>(null)
  40. const [quantity, setQuantity] = useState(currentQuantity)
  41. const [specOptions, setSpecOptions] = useState<SpecOption[]>([])
  42. const [isLoading, setIsLoading] = useState(false)
  43. const [error, setError] = useState<string | null>(null)
  44. // 从API获取子商品数据作为规格选项
  45. useEffect(() => {
  46. // 重置状态
  47. setSpecOptions([])
  48. setSelectedSpec(null)
  49. setError(null)
  50. if (visible && parentGoodsId > 0) {
  51. // 调用真实的子商品列表API
  52. const fetchChildGoods = async () => {
  53. setIsLoading(true)
  54. setError(null)
  55. try {
  56. const response = await goodsClient[':id'].children.$get({
  57. param: { id: parentGoodsId },
  58. query: {
  59. page: 1,
  60. pageSize: 100, // 获取所有子商品,假设不会超过100个
  61. sortBy: 'createdAt',
  62. sortOrder: 'ASC'
  63. }
  64. })
  65. if (response.status === 200) {
  66. const data = await response.json()
  67. // 将子商品数据转换为规格选项格式
  68. const childGoodsAsSpecs: SpecOption[] = data.data.map((goods: GoodsFromApi) => ({
  69. id: goods.id, // 子商品ID
  70. name: goods.name, // 子商品名称作为规格名称
  71. price: goods.price,
  72. stock: goods.stock,
  73. image: goods.imageFile?.fullUrl
  74. }))
  75. setSpecOptions(childGoodsAsSpecs)
  76. // 如果有当前选中的规格,设置选中状态
  77. if (currentSpec) {
  78. const foundSpec = childGoodsAsSpecs.find(spec => spec.name === currentSpec)
  79. if (foundSpec) {
  80. setSelectedSpec(foundSpec)
  81. }
  82. }
  83. } else {
  84. // 尝试解析响应体获取具体错误消息
  85. let errorMsg = `获取子商品列表失败: ${response.status}`
  86. try {
  87. const errorData = await response.json()
  88. if (errorData && errorData.message) {
  89. errorMsg = errorData.message
  90. }
  91. } catch (jsonError) {
  92. console.warn('无法解析错误响应体:', jsonError)
  93. }
  94. console.error('获取子商品列表失败:', { status: response.status, message: errorMsg })
  95. setError(errorMsg)
  96. setSpecOptions([])
  97. }
  98. } catch (error) {
  99. const errorMsg = error instanceof Error ? error.message : '获取子商品列表异常'
  100. console.error('获取子商品列表异常:', error)
  101. setError(errorMsg)
  102. setSpecOptions([])
  103. } finally {
  104. setIsLoading(false)
  105. }
  106. }
  107. fetchChildGoods()
  108. } else {
  109. // 如果不可见或parentGoodsId无效,清空规格选项
  110. setIsLoading(false)
  111. }
  112. }, [visible, parentGoodsId, currentSpec])
  113. const handleSpecSelect = (spec: SpecOption) => {
  114. setSelectedSpec(spec)
  115. // 重置数量为1
  116. setQuantity(1)
  117. }
  118. // 计算总价
  119. const calculateTotalPrice = () => {
  120. if (!selectedSpec) return 0
  121. return selectedSpec.price * quantity
  122. }
  123. // 验证价格计算正确性
  124. const validatePriceCalculation = () => {
  125. if (selectedSpec && quantity > 0) {
  126. const calculatedPrice = calculateTotalPrice()
  127. const expectedPrice = selectedSpec.price * quantity
  128. if (calculatedPrice !== expectedPrice) {
  129. console.error('价格计算错误:', { calculatedPrice, expectedPrice, specPrice: selectedSpec.price, quantity })
  130. }
  131. }
  132. }
  133. // 在数量或规格变化时验证价格计算
  134. useEffect(() => {
  135. validatePriceCalculation()
  136. }, [selectedSpec, quantity])
  137. // 获取最大可购买数量
  138. const getMaxQuantity = () => {
  139. if (!selectedSpec) return 999
  140. return Math.min(selectedSpec.stock, 999)
  141. }
  142. // 处理减少数量
  143. const handleDecrease = () => {
  144. if (!selectedSpec) return
  145. const currentQty = quantity === 0 ? 1 : quantity
  146. const newQuantity = Math.max(1, currentQty - 1)
  147. setQuantity(newQuantity)
  148. }
  149. // 处理增加数量
  150. const handleIncrease = () => {
  151. if (!selectedSpec) return
  152. const currentQty = quantity === 0 ? 1 : quantity
  153. const maxQuantity = getMaxQuantity()
  154. if (currentQty >= maxQuantity) {
  155. if (maxQuantity === selectedSpec.stock) {
  156. Taro.showToast({
  157. title: `库存只有${selectedSpec.stock}件`,
  158. icon: 'none',
  159. duration: 1500
  160. })
  161. } else {
  162. Taro.showToast({
  163. title: '单次最多购买999件',
  164. icon: 'none',
  165. duration: 1500
  166. })
  167. }
  168. return
  169. }
  170. setQuantity(currentQty + 1)
  171. }
  172. // 处理数量输入变化
  173. const handleQuantityChange = (value: string) => {
  174. if (!selectedSpec) return
  175. // 清除非数字字符
  176. const cleanedValue = value.replace(/[^\d]/g, '')
  177. // 如果输入为空,设为空字符串(允许用户删除)
  178. if (cleanedValue === '') {
  179. setQuantity(0) // 设为0表示空输入
  180. return
  181. }
  182. const numValue = parseInt(cleanedValue)
  183. // 验证最小值
  184. if (numValue < 1) {
  185. setQuantity(1)
  186. Taro.showToast({
  187. title: '数量不能小于1',
  188. icon: 'none',
  189. duration: 1500
  190. })
  191. return
  192. }
  193. // 验证最大值
  194. const maxQuantity = getMaxQuantity()
  195. if (numValue > maxQuantity) {
  196. setQuantity(maxQuantity)
  197. if (maxQuantity === selectedSpec.stock) {
  198. Taro.showToast({
  199. title: `库存只有${selectedSpec.stock}件`,
  200. icon: 'none',
  201. duration: 1500
  202. })
  203. } else {
  204. Taro.showToast({
  205. title: '单次最多购买999件',
  206. icon: 'none',
  207. duration: 1500
  208. })
  209. }
  210. return
  211. }
  212. setQuantity(numValue)
  213. }
  214. // 处理输入框失去焦点(完成输入)
  215. const handleQuantityBlur = () => {
  216. // 如果数量小于1(表示空输入或负数),设为1
  217. if (quantity < 1) {
  218. setQuantity(1)
  219. }
  220. }
  221. const getConfirmButtonText = (spec: SpecOption, qty: number, action?: 'add-to-cart' | 'buy-now') => {
  222. const totalPrice = spec.price * qty
  223. const priceText = `¥${totalPrice.toFixed(2)}`
  224. if (action === 'add-to-cart') {
  225. return `加入购物车 (${priceText})`
  226. } else if (action === 'buy-now') {
  227. return `立即购买 (${priceText})`
  228. } else {
  229. return `确定 (${priceText})`
  230. }
  231. }
  232. const handleConfirm = () => {
  233. if (!selectedSpec) {
  234. // 提示用户选择规格
  235. return
  236. }
  237. onConfirm(selectedSpec, quantity, actionType)
  238. onClose()
  239. }
  240. if (!visible) return null
  241. return (
  242. <View className="spec-modal">
  243. <View className="spec-modal-content">
  244. <View className="spec-modal-header">
  245. <Text className="spec-modal-title">选择规格</Text>
  246. <View
  247. className="spec-modal-close"
  248. onClick={onClose}
  249. >
  250. <View className="i-heroicons-x-mark-20-solid" />
  251. </View>
  252. </View>
  253. <ScrollView className="spec-options" scrollY>
  254. {isLoading ? (
  255. <View className="spec-loading">
  256. <View className="i-heroicons-arrow-path-20-solid animate-spin w-8 h-8 text-blue-500" />
  257. <Text className="loading-text">加载规格选项...</Text>
  258. </View>
  259. ) : error ? (
  260. <View className="spec-error">
  261. <View className="i-heroicons-exclamation-triangle-20-solid w-8 h-8 text-red-500" />
  262. <Text className="error-text">{error}</Text>
  263. <Button
  264. size="sm"
  265. variant="outline"
  266. className="retry-btn"
  267. onClick={() => {
  268. // 重新触发数据获取
  269. setSpecOptions([])
  270. setSelectedSpec(null)
  271. setError(null)
  272. setIsLoading(true)
  273. // 这里应该重新调用API,但useEffect会基于依赖项自动重新执行
  274. }}
  275. >
  276. 重试
  277. </Button>
  278. </View>
  279. ) : specOptions.length === 0 ? (
  280. <View className="spec-empty">
  281. <View className="i-heroicons-information-circle-20-solid w-8 h-8 text-gray-400" />
  282. <Text className="empty-text">暂无规格选项</Text>
  283. </View>
  284. ) : (
  285. specOptions.map(spec => (
  286. <View
  287. key={spec.id}
  288. className={`spec-option ${selectedSpec?.id === spec.id ? 'selected' : ''}`}
  289. onClick={() => handleSpecSelect(spec)}
  290. >
  291. <Text className="spec-option-text">{spec.name}</Text>
  292. <View className="spec-option-price">
  293. <Text className="price-text">¥{spec.price.toFixed(2)}</Text>
  294. <Text className="stock-text">库存: {spec.stock}</Text>
  295. </View>
  296. </View>
  297. ))
  298. )}
  299. </ScrollView>
  300. {/* 数量选择器 */}
  301. {selectedSpec && (
  302. <View className="quantity-section">
  303. <Text className="quantity-label">数量</Text>
  304. <View className="quantity-controls">
  305. <Button
  306. size="sm"
  307. variant="ghost"
  308. className="quantity-btn"
  309. onClick={handleDecrease}
  310. >
  311. -
  312. </Button>
  313. <Input
  314. className="quantity-input"
  315. type="number"
  316. value={quantity === 0 ? '' : quantity.toString()}
  317. onInput={(e) => handleQuantityChange(e.detail.value)}
  318. onBlur={handleQuantityBlur}
  319. placeholder="1"
  320. maxlength={3}
  321. confirmType="done"
  322. />
  323. <Button
  324. size="sm"
  325. variant="ghost"
  326. className="quantity-btn"
  327. onClick={handleIncrease}
  328. >
  329. +
  330. </Button>
  331. </View>
  332. </View>
  333. )}
  334. <View className="spec-modal-footer">
  335. <Button
  336. className="spec-confirm-btn"
  337. onClick={handleConfirm}
  338. disabled={!selectedSpec}
  339. >
  340. {selectedSpec ? getConfirmButtonText(selectedSpec, quantity, actionType) : '请选择规格'}
  341. </Button>
  342. </View>
  343. </View>
  344. </View>
  345. )
  346. }