Files
template-10584/src/user/ticket/use.tsx
赵忠林 24d28d0aaa fix(order): 优化收货地址及收货人信息处理逻辑
- 切换开发环境,方便本地调试
- 在订单模型中新增收货人姓名和手机号码字段
- 订单列表中优先显示收货人信息,fallback为客户昵称和电话
- 订单编辑与新建时优化收货地址选择逻辑
- 编辑模式优先使用默认地址,新建模式使用订单关联地址
- 异步获取地址失败时添加错误日志方便排查
2026-05-06 17:46:30 +08:00

1549 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Picker } from '@tarojs/components'
import {
Button,
Cell,
CellGroup,
ConfigProvider,
Empty,
Input,
InputNumber,
Popup,
Space
} from '@nutui/nutui-react-taro'
import { ArrowRight, Location, Ticket } from '@nutui/icons-react-taro'
import dayjs, { type Dayjs } from 'dayjs'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods'
import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
import './use.scss'
import Gap from "@/components/Gap";
import OrderConfirmSkeleton from "@/components/OrderConfirmSkeleton";
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { getGltUserTicket, listGltUserTicket } from '@/api/glt/gltUserTicket'
import { getGltTicketTemplate, getGltTicketTemplateByGoodsId } from '@/api/glt/gltTicketTemplate'
import { addGltTicketOrder, getGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
import { listShopStoreRider } from '@/api/shop/shopStoreRider'
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
const DEFAULT_MIN_START_QTY = 10
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
const [minStartQty, setMinStartQty] = useState<number>(DEFAULT_MIN_START_QTY)
const [quantity, setQuantity] = useState<number>(DEFAULT_MIN_START_QTY)
const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection).
const [sendTime, setSendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('')
const [submitLoading, setSubmitLoading] = useState<boolean>(false)
const loadAllDataLoadingRef = useRef(false)
const hasInitialLoadedRef = useRef(false)
// InputNumber 主题配置
const customTheme = {
nutuiInputnumberButtonWidth: '28px',
nutuiInputnumberButtonHeight: '28px',
nutuiInputnumberInputWidth: '40px',
nutuiInputnumberInputHeight: '28px',
nutuiInputnumberInputBorderRadius: '4px',
nutuiInputnumberButtonBorderRadius: '4px',
}
// 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage
const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([])
const [storeLoading, setStoreLoading] = useState(false)
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const storeAutoPickingRef = useRef(false)
const storeManualPickedRef = useRef(false)
// 水票:用于“立即送水”下单(用水票抵扣,无需支付)
const [tickets, setTickets] = useState<GltUserTicket[]>([])
const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(false)
const [ticketLoaded, setTicketLoaded] = useState(false)
const noTicketPromptedRef = useRef(false)
const ticketAutoRetryCountRef = useRef(0)
const ticketAutoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Delivery range (geofence): block ordering if address/current location is outside.
const [fences, setFences] = useState<ShopStoreFence[]>([])
const fencesLoadedRef = useRef(false)
const fencesPromiseRef = useRef<Promise<ShopStoreFence[]> | null>(null)
const fencesErrorRef = useRef<Error | null>(null)
const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false)
const deliveryRangeCheckingRef = useRef(false)
const [inDeliveryRange, setInDeliveryRange] = useState<boolean | undefined>(undefined)
// Prevent using stale `inDeliveryRange` from a previous address when user switches addresses.
const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState<number | undefined>(undefined)
// 配送方式elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
const [deliveryMethod, setDeliveryMethod] = useState<string>('')
// 步梯是否需要送上楼null=未选择)
const [needCarryUpstairs, setNeedCarryUpstairs] = useState<boolean | null>(null)
// 楼层从2开始需要送上楼时选择
const [deliveryFloor, setDeliveryFloor] = useState<number>(2)
// 楼层选择弹窗
const [floorPickerVisible, setFloorPickerVisible] = useState(false)
// 计算配送费每桶每层1元第1层不收费
const getDeliveryFee = () => {
if (deliveryMethod !== 'stairs' || !needCarryUpstairs) return 0
if (deliveryFloor <= 1) return 0
return displayQty * (deliveryFloor - 1)
}
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const orderId = router?.params?.orderId;
const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId])
const numericOrderId = useMemo(() => {
const n = orderId ? Number(orderId) : undefined
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined
}, [orderId])
const isEditMode = !!numericOrderId
const [editingOrder, setEditingOrder] = useState<GltTicketOrder | null>(null)
const editingInitRef = useRef(false)
const userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [])
const parseTime = (raw?: unknown) => {
if (raw === undefined || raw === null || raw === '') return null
// Compatible with seconds/milliseconds timestamps.
if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw))) {
const n = Number(raw)
if (!Number.isFinite(n)) return null
return dayjs(n < 1e12 ? n * 1000 : n)
}
const d = dayjs(raw as any)
return d.isValid() ? d : null
}
const clampSendDateToToday = (d: Dayjs) => {
const today = dayjs().startOf('day')
if (!d.isValid()) return today
return d.isBefore(today, 'day') ? today : d.startOf('day')
}
const isPendingDeliveryOrder = (o?: Partial<GltTicketOrder> | null) => {
if (!o) return false
const ds = (o as any)?.deliveryStatus
const hasProgress = !!o.sendStartTime || !!o.sendEndTime || !!o.receiveConfirmTime
return (
Number((o as any)?.deleted) !== 1 &&
Number(o.status) !== 1 &&
!hasProgress &&
(ds === 10 || (typeof ds !== 'number' && !!o.riderId))
)
}
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0
const anyT: any = t
const raw =
anyT.availableQty ??
anyT.availableNum ??
anyT.availableCount ??
anyT.remainQty ??
anyT.remainNum ??
anyT.remainCount
const n = Number(raw)
if (Number.isFinite(n)) return n
// Fallback for tenants that don't return `availableQty`.
const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0)
const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0)
const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0)
const computed = (Number.isFinite(total) ? total : 0) - (Number.isFinite(used) ? used : 0) - (Number.isFinite(frozen) ? frozen : 0)
return Number.isFinite(computed) ? computed : 0
}
const usableTickets = useMemo(() => {
const list = (tickets || [])
.filter(t => Number(t?.deleted) !== 1)
// 1 = 冻结(兼容 status 为字符串)
.filter(t => Number(t?.status) !== 1)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t?.id) > 0)
.filter(t => getTicketAvailableQty(t) > 0)
// Some tenants return goodsId as string; coerce before comparison.
.filter((t) => {
if (!numericGoodsId) return true
const tg = Number((t as any)?.goodsId)
const hasGoodsId = Number.isFinite(tg) && tg > 0
return !hasGoodsId || tg === numericGoodsId
})
// Default order in list: older first (reduce disputes). Real consumption order is computed separately.
return list.sort((a, b) => {
const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0
if (ta !== tb) return ta - tb
return (a.id || 0) - (b.id || 0)
})
}, [tickets, numericGoodsId])
const availableTicketTotal = useMemo(() => {
return usableTickets.reduce((sum, t) => sum + getTicketAvailableQty(t), 0)
}, [usableTickets])
// Consume tickets with smaller available qty first; ties: older first.
const ticketsToConsume = useMemo(() => {
const list = [...usableTickets]
return list.sort((a, b) => {
const qa = getTicketAvailableQty(a)
const qb = getTicketAvailableQty(b)
if (qa !== qb) return qa - qb
const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0
if (ta !== tb) return ta - tb
return (a.id || 0) - (b.id || 0)
})
}, [usableTickets])
const noUsableTickets = useMemo(() => {
// Only show "go buy tickets" guidance after we have finished loading.
return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0
}, [ticketLoaded, ticketLoading, usableTickets.length, userId])
// After buying tickets and redirecting here, some backends may issue tickets asynchronously.
// If opened with a `goodsId`, retry a few times to refresh tickets.
useEffect(() => {
if (isEditMode) return
if (!numericGoodsId) return
if (!ticketLoaded || ticketLoading) return
if (usableTickets.length > 0) {
ticketAutoRetryCountRef.current = 0
return
}
if (ticketAutoRetryCountRef.current >= 4) return
if (ticketAutoRetryTimerRef.current) return
const delays = [800, 1500, 2500, 4000]
const delay = delays[ticketAutoRetryCountRef.current] ?? 2500
ticketAutoRetryCountRef.current += 1
ticketAutoRetryTimerRef.current = setTimeout(async () => {
ticketAutoRetryTimerRef.current = null
await loadUserTickets()
}, delay)
}, [isEditMode, numericGoodsId, ticketLoaded, ticketLoading, usableTickets.length])
useEffect(() => {
return () => {
if (ticketAutoRetryTimerRef.current) {
clearTimeout(ticketAutoRetryTimerRef.current)
ticketAutoRetryTimerRef.current = null
}
}
}, [])
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
if (!isEditMode) return Math.max(0, Math.min(stockMax, availableTicketTotal))
const original = Number(editingOrder?.totalNum ?? 0)
const originalSafe = Number.isFinite(original) ? original : 0
const ticketId = Number(editingOrder?.userTicketId ?? 0)
const ticketIdSafe = Number.isFinite(ticketId) && ticketId > 0 ? ticketId : undefined
const rawTicket = ticketIdSafe ? (tickets || []).find(t => Number(t?.id) === ticketIdSafe) : undefined
if (!rawTicket) return Math.max(0, Math.min(stockMax, originalSafe))
const avail = getTicketAvailableQty(rawTicket)
const upper = Math.max(0, avail + originalSafe)
return Math.max(0, Math.min(stockMax, upper))
}, [availableTicketTotal, editingOrder?.totalNum, editingOrder?.userTicketId, goods?.stock, isEditMode, tickets])
const canStartOrder = useMemo(() => {
return maxQuantity >= minStartQty
}, [maxQuantity, minStartQty])
const displayQty = useMemo(() => {
if (!canStartOrder) return 0
return Math.max(minStartQty, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder, minStartQty])
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const distanceMeters = (a: { lng: number; lat: number }, b: { lng: number; lat: number }) => {
const toRad = (x: number) => (x * Math.PI) / 180
const R = 6371000 // meters
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const sin1 = Math.sin(dLat / 2)
const sin2 = Math.sin(dLng / 2)
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
}
const parseStoreCoords = (s: ShopStore) => {
return parseLngLatFromText((s.lngAndLat || s.location || '').trim())
}
const openAddressPage = async () => {
if (isEditMode) {
if (!editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (!isPendingDeliveryOrder(editingOrder)) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
return
}
}
Taro.navigateTo({ url: '/user/address/index' })
}
const loadFences = async (): Promise<ShopStoreFence[]> => {
if (fencesLoadedRef.current) return fences
if (fencesPromiseRef.current) return fencesPromiseRef.current
fencesPromiseRef.current = (async () => {
try {
const list = await listShopStoreFence()
const safe = Array.isArray(list) ? list : []
setFences(safe)
fencesErrorRef.current = null
fencesLoadedRef.current = true
return safe
} catch (e) {
console.error('获取电子围栏失败:', e)
setFences([])
fencesErrorRef.current = e instanceof Error ? e : new Error('获取电子围栏失败')
fencesLoadedRef.current = true
return []
} finally {
fencesPromiseRef.current = null
}
})()
return fencesPromiseRef.current
}
const isPointInFence = async (p: { lng: number; lat: number }): Promise<boolean> => {
const list = fencesLoadedRef.current ? fences : await loadFences()
if (!list.length) {
// If we failed to fetch fences, block ordering (can't verify delivery range).
if (fencesErrorRef.current) throw fencesErrorRef.current
// No fence configured => do not block.
return true
}
const active = (list || [])
.filter(f => f?.status === 0 || f?.status === undefined)
const polygons = active
.map(f => parseFencePoints(f?.points))
.filter(poly => poly.length >= 3)
if (!polygons.length) {
// If backend has active fence rows but points can't be parsed, block and surface the issue.
const hasPointsText = active.some(f => String(f?.points || '').trim().length > 0)
if (hasPointsText) throw new Error('电子围栏数据异常,请联系管理员配置')
// No usable polygon configured => do not block.
return true
}
return pointInAnyPolygon(p, polygons)
}
const getCheckPoint = async (): Promise<{ lng: number; lat: number }> => {
// Immediate water delivery must validate by the delivery address coordinates.
// Falling back to current GPS may allow ordering with an out-of-fence address.
const byAddress = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (byAddress) return byAddress
throw new Error('该收货地址缺少经纬度,请在地址里选择地图定位后重试')
}
const ensureInDeliveryRange = async (): Promise<boolean> => {
if (deliveryRangeCheckingRef.current) return false
deliveryRangeCheckingRef.current = true
setDeliveryRangeChecking(true)
try {
const p = await getCheckPoint()
const ok = await isPointInFence(p)
setInDeliveryRange(ok)
setDeliveryRangeCheckedAddressId(address?.id)
if (!ok) {
Taro.showToast({ title: '不在配送范围内,暂不支持下单', icon: 'none' })
}
return ok
} catch (e: any) {
console.error('配送范围校验失败:', e)
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
// Note: we validate by address coords only; no GPS permission prompt here.
Taro.showToast({ title: e?.message || '配送范围校验失败,请稍后重试', icon: 'none' })
return false
} finally {
setDeliveryRangeChecking(false)
deliveryRangeCheckingRef.current = false
}
}
const loadStores = async (): Promise<ShopStore[]> => {
if (storeLoading) return stores
try {
setStoreLoading(true)
const list = await listShopStore()
const usable = (list || []).filter(s => s?.isDelete !== 1)
setStores(usable)
return usable
} catch (e) {
console.error('获取门店列表失败:', e)
setStores([])
Taro.showToast({title: '获取门店列表失败', icon: 'none'})
return []
} finally {
setStoreLoading(false)
}
}
const ensureStoreDetail = async (s: ShopStore): Promise<ShopStore> => {
if (!s?.id) return s
// If backend already returned "delivery area polygon"/warehouse info, skip extra request.
if (s.points || s.warehouseId) return s
try {
const full = await getShopStore(s.id)
return full || s
} catch (_e) {
return s
}
}
const pickNearestStore = async (p: { lng: number; lat: number }, list: ShopStore[]) => {
const usable = (list || []).filter(s => s?.isDelete !== 1)
if (!usable.length) return null
// 1) If a store has a polygon (points) and the delivery point is inside, prefer those stores.
const inside: { s: ShopStore; d?: number }[] = []
const outside: { s: ShopStore; d?: number }[] = []
for (const s of usable) {
const poly = parseFencePoints(s?.points)
const isInside = poly.length >= 3 ? pointInPolygon(p, poly) : false
const coords = parseStoreCoords(s)
const d = coords ? distanceMeters(p, coords) : undefined
;(isInside ? inside : outside).push({ s, d })
}
const sortByDistanceThenSortNo = (a: { s: ShopStore; d?: number }, b: { s: ShopStore; d?: number }) => {
const da = a.d ?? Number.POSITIVE_INFINITY
const db = b.d ?? Number.POSITIVE_INFINITY
if (da !== db) return da - db
const sa = a.s.sortNumber ?? Number.POSITIVE_INFINITY
const sb = b.s.sortNumber ?? Number.POSITIVE_INFINITY
if (sa !== sb) return sa - sb
return (a.s.id || 0) - (b.s.id || 0)
}
const best = (inside.length ? inside.sort(sortByDistanceThenSortNo) : outside.sort(sortByDistanceThenSortNo))[0]?.s
if (!best) return null
return ensureStoreDetail(best)
}
const resolveStoreForOrder = async (opts?: { silent?: boolean }): Promise<ShopStore | null> => {
// If user already picked a store on this page, don't auto override.
if (storeManualPickedRef.current && selectedStore?.id) return selectedStore
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (!p) {
// If we already have a selected store, allow ordering to continue (can't auto-pick though).
if (!opts?.silent && !selectedStore?.id) {
Taro.showToast({ title: '该地址缺少经纬度,无法自动分配门店,请在地址里选择地图定位后重试', icon: 'none' })
}
return selectedStore?.id ? selectedStore : null
}
if (storeAutoPickingRef.current) return selectedStore?.id ? selectedStore : null
storeAutoPickingRef.current = true
try {
const list = stores.length ? stores : await loadStores()
// If a store is already selected and its polygon contains the point, keep it.
if (selectedStore?.id) {
const full = await ensureStoreDetail(selectedStore)
const poly = parseFencePoints(full?.points)
if (poly.length >= 3 && pointInPolygon(p, poly)) {
if (full !== selectedStore) {
setSelectedStore(full)
saveSelectedStoreToStorage(full)
}
return full
}
}
const best = await pickNearestStore(p, list)
if (best?.id) {
setSelectedStore(best)
saveSelectedStoreToStorage(best)
return best
}
return selectedStore?.id ? selectedStore : null
} finally {
storeAutoPickingRef.current = false
}
}
const resolveAutoRiderForStore = async (storeId: number): Promise<ShopStoreRider | null> => {
try {
const list = await listShopStoreRider({ storeId, status: 1 })
const usable = (list || [])
.filter(r => r?.isDelete !== 1)
// Prefer enabled/online/auto-dispatch riders; fallback to any usable rider.
.sort((a, b) => {
const score = (r: ShopStoreRider) => {
const enabled = r.status === 1 ? 1000 : 0
const online = r.workStatus === 1 ? 100 : (r.workStatus === 2 ? 50 : 0)
const auto = r.autoDispatchEnabled === 1 ? 10 : 0
const prio = Number(r.dispatchPriority || 0)
return enabled + online + auto + prio
}
return score(b) - score(a)
})
return usable[0] || null
} catch (e) {
console.warn('自动获取配送员失败,将由后台/门店人工派单:', e)
return null
}
}
// @ts-ignore
const openStorePopup = async () => {
setStorePopupVisible(true)
if (!stores.length) {
await loadStores()
}
}
// 处理数量变化
const handleQuantityChange = (value: string | number) => {
const parsed = typeof value === 'string' ? parseInt(value) : value
const newQuantity = Number.isFinite(parsed) ? Number(parsed) : 0
const upper = maxQuantity
if (!canStartOrder || upper <= 0) {
setQuantity(0)
return
}
setQuantity(Math.max(minStartQty, Math.min(newQuantity || minStartQty, upper)))
}
const loadUserTickets = async () => {
if (ticketLoading) return
if (!userId) {
setTickets([])
setTicketLoaded(false)
return
}
try {
setTicketLoading(true)
// Do not pass `status` here: some backends use different status semantics;
// we filter out frozen tickets on the client for compatibility.
const list = await listGltUserTicket({ userId })
setTickets(list || [])
} catch (e) {
console.error('获取水票失败:', e)
setTickets([])
Taro.showToast({ title: '获取水票失败', icon: 'none' })
} finally {
setTicketLoading(false)
setTicketLoaded(true)
}
}
const goBuyTickets = async () => {
try {
setTicketPopupVisible(false)
// If this page is opened with a goodsId, guide user back to that goods detail to purchase.
if (numericGoodsId) {
await Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${numericGoodsId}` })
return
}
await Taro.switchTab({ url: '/pages/index/index' })
} catch (e) {
console.error('跳转购买水票失败:', e)
Taro.showToast({ title: '跳转失败,请稍后重试', icon: 'none' })
}
}
const onSubmit = async () => {
if (submitLoading) return
if (deliveryRangeCheckingRef.current) return
// 基础校验
if (!userId) {
Taro.showToast({ title: '请先登录', icon: 'none' })
return
}
if (isEditMode && !editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (isEditMode && !isPendingDeliveryOrder(editingOrder)) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
return
}
if (!address?.id) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
// 配送方式校验(必选)
if (!deliveryMethod) {
Taro.showToast({ title: '请选择配送方式', icon: 'none' })
return
}
// 步梯场景:必须选择是否送上楼
if (deliveryMethod === 'stairs' && needCarryUpstairs === null) {
Taro.showToast({ title: '请选择是否需要送上楼', icon: 'none' })
return
}
if (!addressHasCoords) {
Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' })
return
}
// Ensure ticket list is loaded.
if (ticketLoading) {
Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' })
return
}
if (!ticketLoaded) {
await loadUserTickets()
}
const storeForOrder = await resolveStoreForOrder()
if (!storeForOrder?.id) {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return
}
if (!isEditMode && availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
}
const finalQty = displayQty
if (finalQty <= 0) {
Taro.showToast({ title: '请选择送水数量', icon: 'none' })
return
}
if (!isEditMode && finalQty > availableTicketTotal) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (isEditMode && finalQty > maxQuantity) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods?.stock !== undefined && finalQty > goods.stock) {
Taro.showToast({ title: '商品库存不足', icon: 'none' })
return
}
if (finalQty < minStartQty) {
Taro.showToast({ title: `最低起送 ${minStartQty}`, icon: 'none' })
return
}
if (!sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
// 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange()
if (!ok) return
const deliveryFee = getDeliveryFee()
const confirmContent = isEditMode
? `配送时间:${sendTimeText}\n送水数量${finalQty}\n配送方式${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}是否确认修改?`
: `配送时间:${sendTimeText}\n配送方式${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
const confirmRes = await Taro.showModal({
title: isEditMode ? '确认修改' : '确认下单',
content: confirmContent
})
if (!confirmRes.confirm) return
try {
setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' })
if (isEditMode) {
await updateGltTicketOrder({
id: editingOrder?.id,
addressId: address.id,
totalNum: finalQty,
buyerRemarks: orderRemark,
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
deliveryMethod,
deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined,
deliveryFee: getDeliveryFee() || undefined
})
} else {
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
// Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
// Consume tickets with smaller available qty first.
let remain = finalQty
for (const t of ticketsToConsume) {
if (remain <= 0) break
const avail = getTicketAvailableQty(t)
const useQty = Math.min(remain, avail)
if (useQty <= 0) continue
await addGltTicketOrder({
userTicketId: Number(t.id),
storeId: storeForOrder.id,
addressId: address.id,
totalNum: useQty,
buyerRemarks: orderRemark,
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
// Backend may take userId from token; pass-through is harmless if backend ignores it.
userId,
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
riderName: autoRider?.realName,
riderPhone: autoRider?.mobile,
comments: goods?.name ? `立即送水:${goods.name}` : '立即送水',
// 配送方式信息
deliveryMethod,
deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined,
deliveryFee: getDeliveryFee() || undefined
})
remain -= useQty
}
if (remain > 0) {
// Ticket counts might have changed between loading and submission.
throw new Error('水票可用次数不足,请刷新后重试')
}
}
await loadUserTickets()
Taro.showToast({ title: isEditMode ? '修改成功' : '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800)
} catch (e: any) {
console.error(isEditMode ? '送水订单修改失败:' : '水票下单失败:', e)
Taro.showToast({ title: e?.message || (isEditMode ? '修改失败' : '下单失败'), icon: 'none' })
} finally {
Taro.hideLoading()
setSubmitLoading(false)
}
}
// 统一的数据加载函数
const loadAllData = async (opts?: { silent?: boolean }) => {
if (loadAllDataLoadingRef.current) return
loadAllDataLoadingRef.current = true
try {
if (!opts?.silent) setLoading(true)
setError('')
const [addressRes, editingOrderRes, goodsByParam] = await Promise.all([
listShopUserAddress({ isDefault: true }),
numericOrderId ? getGltTicketOrder(numericOrderId) : Promise.resolve(null),
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null)
])
let goodsRes = goodsByParam
if (!goodsRes && editingOrderRes?.userTicketId) {
const ticketId = Number(editingOrderRes.userTicketId)
if (Number.isFinite(ticketId) && ticketId > 0) {
try {
const ticket = await getGltUserTicket(ticketId)
const gid = Number((ticket as any)?.goodsId)
if (Number.isFinite(gid) && gid > 0) {
goodsRes = await getShopGoods(gid)
}
} catch (e) {
console.error('加载订单关联商品失败:', e)
}
}
}
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
hasInitialLoadedRef.current = true
}
// 设置默认收货地址
if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0])
}
if (numericOrderId && editingOrderRes && !editingInitRef.current) {
editingInitRef.current = true
setEditingOrder(editingOrderRes)
Taro.setNavigationBarTitle({ title: '订单确认' })
const isPending = isPendingDeliveryOrder(editingOrderRes)
if (!isPending) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
setTimeout(() => {
Taro.navigateBack()
}, 600)
return
}
const initQty = Number(editingOrderRes.totalNum ?? minStartQty)
setQuantity(Number.isFinite(initQty) && initQty > 0 ? initQty : minStartQty)
setOrderRemark(String(editingOrderRes.buyerRemarks || ''))
const st = parseTime(editingOrderRes.sendTime)
if (st) setSendTime(clampSendDateToToday(st).toDate())
// 回显配送方式
if (editingOrderRes.deliveryMethod) {
setDeliveryMethod(editingOrderRes.deliveryMethod)
if (editingOrderRes.deliveryMethod === 'stairs') {
const hasFloor = editingOrderRes.deliveryFloor && editingOrderRes.deliveryFloor > 1
setNeedCarryUpstairs(hasFloor)
if (hasFloor) setDeliveryFloor(editingOrderRes.deliveryFloor)
}
}
// 编辑模式下优先使用默认地址(用户刚从地址列表选择的)
// 新下单模式使用订单关联的地址
let targetAddr: ShopUserAddress | undefined = undefined
if (isEditMode) {
// 编辑模式:优先使用默认地址
targetAddr = addressRes?.find(a => a.isDefault) || addressRes?.[0]
} else {
// 新下单模式:使用订单关联的地址
const addrId = Number(editingOrderRes?.addressId)
const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined
if (addrIdSafe) {
targetAddr = addressRes?.find(a => Number(a?.id) === addrIdSafe)
if (!targetAddr?.id) {
try {
targetAddr = await getShopUserAddress(addrIdSafe)
} catch (e) {
console.error('加载订单收货地址失败:', e)
}
}
}
}
if (targetAddr?.id) {
setAddress(targetAddr)
}
}
// Tickets are non-blocking for first paint; load in background.
loadUserTickets()
} catch (err) {
console.error('加载数据失败:', err)
if (opts?.silent) {
Taro.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
} else {
setError('加载数据失败,请重试')
}
} finally {
if (!opts?.silent) setLoading(false)
loadAllDataLoadingRef.current = false
}
}
useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店
setSelectedStore(getSelectedStoreFromStorage())
ticketAutoRetryCountRef.current = 0
if (ticketAutoRetryTimerRef.current) {
clearTimeout(ticketAutoRetryTimerRef.current)
ticketAutoRetryTimerRef.current = null
}
loadAllData({ silent: hasInitialLoadedRef.current })
})
const addressHasCoords = useMemo(() => {
if (!address?.id) return false
return !!parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
}, [address?.id, address?.lng, address?.lat])
// Auto-pick nearest store by delivery address (best-effort, won't override manual selection).
useEffect(() => {
if (!address?.id) return
if (storeManualPickedRef.current) return
resolveStoreForOrder({ silent: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address?.id, address?.lng, address?.lat])
// Background pre-check when we already have an address coordinate (no permission prompt).
useEffect(() => {
let cancelled = false
;(async () => {
if (!address?.id) {
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
return
}
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (!p) {
// Cannot validate without address coords -> treat as out of range to block ordering.
setInDeliveryRange(false)
setDeliveryRangeCheckedAddressId(address.id)
return
}
// Avoid keeping stale state from previous address while we validate this one.
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
let ok = true
try {
ok = await isPointInFence(p)
} catch (_e) {
// Pre-check is best-effort; don't block UI here.
if (!cancelled) {
setInDeliveryRange(undefined)
setDeliveryRangeCheckedAddressId(undefined)
}
return
}
if (cancelled) return
setInDeliveryRange(ok)
setDeliveryRangeCheckedAddressId(address.id)
})()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address?.id, address?.lng, address?.lat])
// When user changes the delivery address to an out-of-fence one, prompt immediately (once per address).
const outOfRangePromptedAddressIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
const id = address?.id
if (!id) return
if (deliveryRangeCheckedAddressId !== id) return
if (inDeliveryRange !== false) return
if (outOfRangePromptedAddressIdRef.current === id) return
outOfRangePromptedAddressIdRef.current = id
Taro.showToast({ title: addressHasCoords ? '该地址不在配送范围,请更换围栏内地址' : '该地址缺少定位,请在地址里选择地图定位后重试', icon: 'none' })
}, [
address?.id,
addressHasCoords,
deliveryRangeCheckedAddressId,
inDeliveryRange
])
// When tickets/stock change, clamp quantity into [0..maxQuantity].
useEffect(() => {
setQuantity(prev => {
if (maxQuantity <= 0) return 0
if (maxQuantity < minStartQty) return 0
if (!prev || prev < minStartQty) return minStartQty
return Math.min(prev, maxQuantity)
})
}, [maxQuantity, minStartQty])
const minStartQtyKey = useMemo(() => {
const gid = Number(goods?.goodsId)
if (Number.isFinite(gid) && gid > 0) return `g:${gid}`
// If there is exactly one ticket template available, infer min start qty from it (covers "稍后再送" without goodsId).
const ids = Array.from(
new Set(
(usableTickets || [])
.map(t => Number(t?.templateId))
.filter(id => Number.isFinite(id) && id > 0)
)
)
if (ids.length === 1) return `t:${ids[0]}`
return ''
}, [goods?.goodsId, usableTickets])
// Use configured min start-send qty from ticket template (by goodsId or by user's unique templateId).
useEffect(() => {
let cancelled = false
;(async () => {
try {
if (!minStartQtyKey) {
setMinStartQty(DEFAULT_MIN_START_QTY)
return
}
const [kind, rawId] = minStartQtyKey.split(':')
const id = Number(rawId)
const tpl =
kind === 'g'
? await getGltTicketTemplateByGoodsId(id)
: await getGltTicketTemplate(id)
const n = Number(tpl?.startSendQty)
const safe = Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_START_QTY
if (!cancelled) setMinStartQty(safe)
} catch (_e) {
if (!cancelled) setMinStartQty(DEFAULT_MIN_START_QTY)
}
})()
return () => {
cancelled = true
}
}, [minStartQtyKey])
// If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
useEffect(() => {
if (!noUsableTickets) return
// Editing an existing order: don't interrupt with "no tickets" prompt.
if (isEditMode) return
if (noTicketPromptedRef.current) return
noTicketPromptedRef.current = true
// ;(async () => {
// const r = await Taro.showModal({
// title: '暂无可用水票',
// content: '您当前没有可用水票,购买后再来下单更方便。',
// confirmText: '去购买',
// cancelText: '暂不'
// })
// if (r.confirm) {
// await goBuyTickets()
// }
// })()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noUsableTickets, isEditMode])
// 重新加载数据
const handleRetry = () => {
loadAllData()
}
// 错误状态
if (error) {
return (
<View className="order-confirm-page">
<View className="error-state">
<Text className="error-text">{error}</Text>
<Button onClick={handleRetry}></Button>
</View>
</View>
)
}
// 加载状态
if (loading) {
return <OrderConfirmSkeleton/>
}
return (
<div className={'order-confirm-page'}>
{/*<CellGroup>*/}
{/* <Cell*/}
{/* title={(*/}
{/* <View className="flex items-center gap-2">*/}
{/* <Shop className={'text-gray-500'}/>*/}
{/* <Text>选择门店</Text>*/}
{/* </View>*/}
{/* )}*/}
{/* extra={(*/}
{/* <View className={'flex items-center gap-2'}>*/}
{/* <View className={'text-gray-900'}>*/}
{/* {selectedStore?.name || '请选择门店'}*/}
{/* </View>*/}
{/* <ArrowRight className={'text-gray-400'} size={14}/>*/}
{/* </View>*/}
{/* )}*/}
{/* onClick={openStorePopup}*/}
{/* />*/}
{/*</CellGroup>*/}
<CellGroup>
{
address && (
<Cell
className={'address-bottom-line'}
onClick={openAddressPage}
>
<Space>
<Location className={'text-gray-500'}/>
<View className={'flex flex-col w-full justify-between items-start'}>
<Space className={'flex flex-row w-full'}>
<View className={'flex-wrap text-nowrap whitespace-nowrap text-gray-500'}></View>
<View className={'font-medium text-sm flex items-center w-full'}>
<View
style={{width: '64%'}}>{address.province} {address.city} {address.region} {address.address}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
</Space>
<View className={'pt-1 pb-3'}>
<View className={'text-gray-500'}>{address.name} {address.phone}</View>
</View>
</View>
</Space>
</Cell>
)
}
{!address && (
<Cell className={''} onClick={openAddressPage}>
<Space>
<Location/>
</Space>
</Cell>
)}
</CellGroup>
{/* 配送方式选择(必选) */}
<CellGroup className={'delivery-method-group'}>
<Cell>
<View className={'delivery-method-section'}>
<View className={'delivery-method-label'}>
<Text className={'font-medium text-sm'}></Text>
<Text className={'text-red-500 text-xs ml-1'}>*</Text>
</View>
<View className={'delivery-method-options'}>
{[
{ key: 'elevator', label: '电梯', icon: '🏛️' },
{ key: 'stairs', label: '步梯', icon: '🚶' },
{ key: 'groundFloor', label: '一楼商铺/其他', icon: '🏪' },
].map(item => (
<View
key={item.key}
className={`delivery-method-item ${deliveryMethod === item.key ? 'active' : ''}`}
onClick={() => {
setDeliveryMethod(item.key)
setNeedCarryUpstairs(null)
setDeliveryFloor(2)
}}
>
<Text className={'delivery-method-icon'}>{item.icon}</Text>
<Text className={'text-sm'}>{item.label}</Text>
</View>
))}
</View>
{/* 步梯:是否需要送上楼 */}
{deliveryMethod === 'stairs' && (
<View className={'carry-upstairs-section'}>
<Text className={'text-sm text-gray-600 mb-2'}></Text>
<View className={'carry-upstairs-options'}>
<View
className={`carry-upstairs-item ${needCarryUpstairs === true ? 'active' : ''}`}
onClick={() => setNeedCarryUpstairs(true)}
>
<Text></Text>
</View>
<View
className={`carry-upstairs-item ${needCarryUpstairs === false ? 'active' : ''}`}
onClick={() => {
setNeedCarryUpstairs(false)
setDeliveryFloor(2)
}}
>
<Text></Text>
</View>
</View>
</View>
)}
{/* 步梯+送上楼:选择楼层 */}
{deliveryMethod === 'stairs' && needCarryUpstairs === true && (
<View className={'floor-select-section'}>
<Text className={'text-sm text-gray-600'}></Text>
<View
className={'floor-select-btn'}
onClick={() => setFloorPickerVisible(true)}
>
<Text className={deliveryFloor > 1 ? 'text-gray-900' : 'text-gray-400'}>
{deliveryFloor > 1 ? `${deliveryFloor}` : '请选择楼层'}
</Text>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
{deliveryFloor > 1 && (
<View className={'floor-fee-tip'}>
<Text className={'text-xs text-orange-500'}>
{displayQty} x {deliveryFloor - 1} = {getDeliveryFee().toFixed(2)}
</Text>
</View>
)}
</View>
)}
</View>
</Cell>
</CellGroup>
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<Picker
mode="date"
start={dayjs().format('YYYY-MM-DD')}
value={dayjs(sendTime).format('YYYY-MM-DD')}
onChange={(e) => {
const v = (e as any)?.detail?.value
const d = dayjs(v)
if (!d.isValid()) return
if (d.isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
setSendTime(d.startOf('day').toDate())
}}
>
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14} />
</View>
</Picker>
)}
/>
</CellGroup>
<CellGroup>
<Cell
title={'送水数量'}
description={
canStartOrder
? `最低起送 ${minStartQty}`
: `最低起送 ${minStartQty} 桶(当前最多 ${maxQuantity} 桶)`
}
extra={(
<ConfigProvider theme={customTheme}>
<InputNumber
value={displayQty}
min={canStartOrder ? minStartQty : 0}
max={canStartOrder ? maxQuantity : 0}
step={minStartQty >= 10 ? 10 : 1}
readOnly
disabled={!canStartOrder}
onChange={handleQuantityChange}
/>
</ConfigProvider>
)}
/>
</CellGroup>
<CellGroup>
<Cell
title={(
<View className="flex items-center gap-2">
<Ticket className={'text-gray-500'}/>
<Text></Text>
</View>
)}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>
{ticketLoading
? '加载中...'
: (ticketLoaded
? (noUsableTickets
? '暂无可用水票'
: `可用合计 ${availableTicketTotal}${usableTickets.length}组)`
)
: '点击查看'
)
}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={async () => {
if (ticketLoading) return
if (!ticketLoaded) {
setTicketPopupVisible(true)
await loadUserTickets()
return
}
if (noUsableTickets && !isEditMode) {
const r = await Taro.showModal({
title: '暂无可用水票',
content: '您还没有可用水票,是否前往购买?',
confirmText: '去购买',
cancelText: '暂不'
})
if (r.confirm) await goBuyTickets()
return
}
setTicketPopupVisible(true)
}}
/>
{(noUsableTickets && !isEditMode) && (
<Cell
title={<Text className="text-gray-500"></Text>}
description="购买水票后即可在这里直接下单送水"
extra={(
<Button type="primary" size="small" onClick={goBuyTickets}>
</Button>
)}
/>
)}
<Cell
title={'本次使用'}
extra={<View className={'font-medium'}>{displayQty} </View>}
/>
</CellGroup>
<CellGroup>
<Cell title={'备注'} extra={(
<Input
placeholder={'(选填)请填写备注'}
style={{padding: '0'}}
value={orderRemark}
onChange={(value) => setOrderRemark(value)}
maxLength={100}
/>
)}/>
</CellGroup>
{/* 水票明细弹窗 */}
<Popup
visible={ticketPopupVisible}
position="bottom"
style={{ height: '70vh' }}
onClose={() => setTicketPopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setTicketPopupVisible(false)}
>
</Text>
</View>
{!!usableTickets.length && !ticketLoading && (
<View className="text-xs text-gray-500 mb-2">
<Text> {availableTicketTotal} 使</Text>
</View>
)}
{ticketLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<>
{!!usableTickets.length ? (
<CellGroup>
{ticketsToConsume.map((t) => {
return (
<Cell
key={t.id}
title={<Text> {t.id}</Text>}
description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
extra={<Text className="text-gray-700"> {getTicketAvailableQty(t)}</Text>}
onClick={() => setTicketPopupVisible(false)}
/>
)
})}
</CellGroup>
) : (
<View className="py-10 text-center">
<Empty description="暂无可用水票" />
<View className="mt-4 flex justify-center">
<Button
type="primary"
onClick={isEditMode ? () => setTicketPopupVisible(false) : goBuyTickets}
>
{isEditMode ? '确定修改' : '确定下单'}
</Button>
</View>
</View>
)}
</>
)}
</View>
</Popup>
{/* 门店选择弹窗 */}
<Popup
visible={storePopupVisible}
position="bottom"
style={{height: '70vh'}}
onClose={() => setStorePopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setStorePopupVisible(false)}
>
</Text>
</View>
{storeLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<CellGroup>
{stores.map((s) => {
const isActive = !!selectedStore?.id && selectedStore.id === s.id
return (
<Cell
key={s.id}
title={<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>}
description={s.address || ''}
onClick={async () => {
let storeToSave: ShopStore = s
if (s?.id) {
try {
const full = await getShopStore(s.id)
if (full) storeToSave = full
} catch (_e) {
// keep base item
}
}
setSelectedStore(storeToSave)
storeManualPickedRef.current = true
saveSelectedStoreToStorage(storeToSave)
setStorePopupVisible(false)
Taro.showToast({title: '门店已切换', icon: 'success'})
}}
/>
)
})}
{!stores.length && (
<Cell title={<Text className="text-gray-500"></Text>} />
)}
</CellGroup>
)}
</View>
</Popup>
{/* 楼层选择弹窗 */}
<Popup
visible={floorPickerVisible}
position="bottom"
onClose={() => setFloorPickerVisible(false)}
style={{height: '40vh'}}
>
<View className="floor-picker-popup">
<View className="floor-picker-popup__header">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setFloorPickerVisible(false)}
>
</Text>
</View>
<View className="floor-picker-popup__content">
<View className="floor-grid">
{Array.from({length: 32}, (_, i) => i + 2).map(f => (
<View
key={f}
className={`floor-grid-item ${deliveryFloor === f ? 'active' : ''}`}
onClick={() => {
setDeliveryFloor(f)
setFloorPickerVisible(false)
}}
>
<Text>{f}</Text>
</View>
))}
</View>
</View>
{deliveryFloor > 1 && (
<View className="floor-picker-popup__footer">
<Text className={'text-sm text-gray-600'}>
{displayQty} x {deliveryFloor - 1} = <Text className={'text-red-500 font-bold'}>{(displayQty * (deliveryFloor - 1)).toFixed(2)}</Text>
</Text>
</View>
)}
</View>
</Popup>
<Gap height={50}/>
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10 border-t border-gray-200'}>
<View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}>
<View className={'flex items-center gap-2'}>
<span className={'total-price text-sm text-gray-500'}>使</span>
<span className={'text-red-500 text-xl font-bold'}>
{displayQty}
</span>
<span className={'text-sm text-gray-500'}></span>
</View>
{getDeliveryFee() > 0 && (
<View className={'text-xs text-orange-500'}>
{getDeliveryFee().toFixed(2)}
</View>
)}
</div>
<div className={'buy-btn mx-4'}>
{noUsableTickets && !isEditMode ? (
<Button type="primary" size="large" onClick={goBuyTickets}>
{isEditMode ? '确定修改' : '确定下单'}
</Button>
) : (
<Button
type="success"
size="large"
loading={submitLoading || deliveryRangeChecking}
disabled={
deliveryRangeChecking ||
!address?.id ||
!addressHasCoords ||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
(!isEditMode && availableTicketTotal <= 0) ||
!canStartOrder ||
!deliveryMethod ||
(deliveryMethod === 'stairs' && needCarryUpstairs === null)
}
onClick={onSubmit}
>
{deliveryRangeChecking
? '校验配送范围...'
: (!address?.id
? '请选择地址'
: (!addressHasCoords
? '地址缺少定位'
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false)
? '不在配送范围'
: (!deliveryMethod
? '请选配送方式'
: (deliveryMethod === 'stairs' && needCarryUpstairs === null
? '请选是否送上楼'
: (submitLoading ? '提交中...' : (isEditMode ? '确定修改' : '立即提交'))
)
)
)
)
)
}
</Button>
)}
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;