- 仅在用户允许修改门票配送地址时显示提示 - 避免在冷却窗口期间显示冗余提示 - 添加对地址修改限制状态的检查 - 更新 useEffect 依赖数组以包含地址修改限制状态
1295 lines
48 KiB
TypeScript
1295 lines
48 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import Taro, { useDidShow } from '@tarojs/taro'
|
||
import { View, Text } 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 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 { listGltUserTicket } from '@/api/glt/gltUserTicket'
|
||
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
|
||
import { pageGltTicketOrder } 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 MIN_START_QTY = 10
|
||
const ADDRESS_CHANGE_COOLDOWN_DAYS = 30
|
||
|
||
const OrderConfirm = () => {
|
||
const [goods, setGoods] = useState<ShopGoods | null>(null);
|
||
const [address, setAddress] = useState<ShopUserAddress>()
|
||
const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
|
||
const [orderRemark, setOrderRemark] = useState<string>('')
|
||
// Delivery date only (no hour/min selection).
|
||
const [sendTime] = 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)
|
||
|
||
// 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)
|
||
|
||
const router = Taro.getCurrentInstance().router;
|
||
const goodsId = router?.params?.goodsId;
|
||
const numericGoodsId = useMemo(() => {
|
||
const n = goodsId ? Number(goodsId) : undefined
|
||
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
|
||
}, [goodsId])
|
||
|
||
const userId = useMemo(() => {
|
||
const raw = Taro.getStorageSync('UserId')
|
||
const id = Number(raw)
|
||
return Number.isFinite(id) && id > 0 ? id : undefined
|
||
}, [])
|
||
|
||
type TicketAddressModifyLimit = {
|
||
loaded: boolean
|
||
canModify: boolean
|
||
nextAllowedText?: string
|
||
lockedAddressId?: number
|
||
}
|
||
const [ticketAddressModifyLimit, setTicketAddressModifyLimit] = useState<TicketAddressModifyLimit>({
|
||
loaded: false,
|
||
canModify: true,
|
||
})
|
||
const ticketAddressModifyLimitPromiseRef = useRef<Promise<TicketAddressModifyLimit> | null>(null)
|
||
|
||
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 getOrderTime = (o?: Partial<GltTicketOrder> | null) => {
|
||
return parseTime(o?.createTime) || parseTime(o?.updateTime)
|
||
}
|
||
|
||
const getOrderAddressKey = (o?: Partial<GltTicketOrder> | null) => {
|
||
const id = Number(o?.addressId)
|
||
if (Number.isFinite(id) && id > 0) return `id:${id}`
|
||
const txt = String(o?.address || '').trim()
|
||
if (txt) return `txt:${txt}`
|
||
return ''
|
||
}
|
||
|
||
const loadTicketAddressModifyLimit = async (): Promise<TicketAddressModifyLimit> => {
|
||
if (ticketAddressModifyLimitPromiseRef.current) return ticketAddressModifyLimitPromiseRef.current
|
||
|
||
ticketAddressModifyLimitPromiseRef.current = (async () => {
|
||
if (!userId) return { loaded: true, canModify: true }
|
||
|
||
const now = dayjs()
|
||
const pageSize = 20
|
||
let page = 1
|
||
const all: GltTicketOrder[] = []
|
||
|
||
let latestKey = ''
|
||
let latestAddressId: number | undefined = undefined
|
||
|
||
while (true) {
|
||
const res = await pageGltTicketOrder({ page, limit: pageSize, userId })
|
||
const list = Array.isArray(res?.list) ? res.list : []
|
||
if (page === 1) {
|
||
const first = list[0]
|
||
latestKey = getOrderAddressKey(first)
|
||
const id = Number(first?.addressId)
|
||
latestAddressId = Number.isFinite(id) && id > 0 ? id : undefined
|
||
}
|
||
|
||
if (!list.length) break
|
||
all.push(...list)
|
||
|
||
// Find the oldest order in the newest contiguous block of the latest address key.
|
||
// That order's time represents the last time user "set/changed" the ticket delivery address.
|
||
const currentKey = latestKey
|
||
if (!currentKey) {
|
||
return { loaded: true, canModify: true }
|
||
}
|
||
|
||
let lastSameIndex = 0
|
||
let foundDifferent = false
|
||
for (let i = 1; i < all.length; i++) {
|
||
const k = getOrderAddressKey(all[i])
|
||
if (!k) continue
|
||
if (k === currentKey) {
|
||
lastSameIndex = i
|
||
continue
|
||
}
|
||
foundDifferent = true
|
||
break
|
||
}
|
||
|
||
if (foundDifferent) {
|
||
const lastSetAt = getOrderTime(all[lastSameIndex])
|
||
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
|
||
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
|
||
const canModify = now.isAfter(nextAllowed)
|
||
return {
|
||
loaded: true,
|
||
canModify,
|
||
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
|
||
lockedAddressId: latestAddressId,
|
||
}
|
||
}
|
||
|
||
const oldest = getOrderTime(all[all.length - 1])
|
||
if (oldest && now.diff(oldest, 'day') >= ADDRESS_CHANGE_COOLDOWN_DAYS) {
|
||
// We have enough history beyond the cooldown window, and still no different address found.
|
||
return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
|
||
}
|
||
|
||
const totalCount = typeof (res as any)?.count === 'number' ? Number((res as any).count) : undefined
|
||
if (totalCount !== undefined && all.length >= totalCount) break
|
||
if (list.length < pageSize) break
|
||
|
||
page += 1
|
||
if (page > 10) break // safety: avoid excessive paging
|
||
}
|
||
|
||
if (!all.length) return { loaded: true, canModify: true }
|
||
|
||
// If we can't prove the last-set time is older than the cooldown window, be conservative and lock.
|
||
const lastSetAt = getOrderTime(all[all.length - 1])
|
||
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
|
||
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
|
||
const canModify = now.isAfter(nextAllowed)
|
||
return {
|
||
loaded: true,
|
||
canModify,
|
||
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
|
||
lockedAddressId: latestAddressId,
|
||
}
|
||
})()
|
||
.finally(() => {
|
||
ticketAddressModifyLimitPromiseRef.current = null
|
||
})
|
||
|
||
return ticketAddressModifyLimitPromiseRef.current
|
||
}
|
||
|
||
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])
|
||
|
||
const maxQuantity = useMemo(() => {
|
||
const stockMax = goods?.stock ?? 999
|
||
return Math.max(0, Math.min(stockMax, availableTicketTotal))
|
||
}, [availableTicketTotal, goods?.stock])
|
||
|
||
const canStartOrder = useMemo(() => {
|
||
return maxQuantity >= MIN_START_QTY
|
||
}, [maxQuantity])
|
||
|
||
const displayQty = useMemo(() => {
|
||
if (!canStartOrder) return 0
|
||
return Math.max(MIN_START_QTY, Math.min(quantity, maxQuantity))
|
||
}, [quantity, maxQuantity, canStartOrder])
|
||
|
||
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 () => {
|
||
const limit = ticketAddressModifyLimit.loaded
|
||
? ticketAddressModifyLimit
|
||
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
|
||
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
|
||
|
||
if (!limit.canModify) {
|
||
Taro.showToast({
|
||
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次${limit.nextAllowedText ? ',' + limit.nextAllowedText + ' 后可修改' : ''}`,
|
||
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(MIN_START_QTY, Math.min(newQuantity || MIN_START_QTY, 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 (!goods?.goodsId) return
|
||
|
||
// 基础校验
|
||
if (!userId) {
|
||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||
return
|
||
}
|
||
if (!address?.id) {
|
||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
// Ticket delivery address is based on order snapshot. Enforce "once per 30 days" by latest ticket-order history.
|
||
const limit = ticketAddressModifyLimit.loaded
|
||
? ticketAddressModifyLimit
|
||
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
|
||
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
|
||
if (!limit.canModify && limit.lockedAddressId && address.id !== limit.lockedAddressId) {
|
||
Taro.showToast({
|
||
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次,请使用上次下单地址${limit.nextAllowedText ? '(' + limit.nextAllowedText + ' 后可修改)' : ''}`,
|
||
icon: 'none',
|
||
})
|
||
try {
|
||
const locked = await getShopUserAddress(limit.lockedAddressId)
|
||
if (locked?.id) setAddress(locked)
|
||
} catch (_e) {
|
||
// ignore: keep current address, but still block submission
|
||
}
|
||
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 (availableTicketTotal <= 0) {
|
||
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const finalQty = displayQty
|
||
if (finalQty <= 0) {
|
||
Taro.showToast({ title: '请选择送水数量', icon: 'none' })
|
||
return
|
||
}
|
||
if (finalQty > availableTicketTotal) {
|
||
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
|
||
return
|
||
}
|
||
if (goods.stock !== undefined && finalQty > goods.stock) {
|
||
Taro.showToast({ title: '商品库存不足', icon: 'none' })
|
||
return
|
||
}
|
||
if (finalQty < MIN_START_QTY) {
|
||
Taro.showToast({ title: `最低起送 ${MIN_START_QTY} 桶`, icon: 'none' })
|
||
return
|
||
}
|
||
if (!sendTime) {
|
||
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
// 配送范围校验(电子围栏)
|
||
const ok = await ensureInDeliveryRange()
|
||
if (!ok) return
|
||
|
||
const confirmRes = await Taro.showModal({
|
||
title: '确认下单',
|
||
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
|
||
})
|
||
if (!confirmRes.confirm) return
|
||
|
||
try {
|
||
setSubmitLoading(true)
|
||
Taro.showLoading({ title: '提交中...' })
|
||
|
||
// 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
|
||
let created = 0
|
||
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}` : '立即送水'
|
||
})
|
||
remain -= useQty
|
||
created += 1
|
||
}
|
||
|
||
if (remain > 0) {
|
||
// Ticket counts might have changed between loading and submission.
|
||
throw new Error('水票可用次数不足,请刷新后重试')
|
||
}
|
||
|
||
await loadUserTickets()
|
||
|
||
Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' })
|
||
setTimeout(() => {
|
||
// 跳转到“我的送水订单”
|
||
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
|
||
}, 800)
|
||
} catch (e: any) {
|
||
console.error('水票下单失败:', e)
|
||
Taro.showToast({ title: e?.message || '下单失败', 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 [goodsRes, addressRes] = await Promise.all([
|
||
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null),
|
||
listShopUserAddress({ isDefault: true })
|
||
])
|
||
|
||
// 设置商品信息
|
||
if (goodsRes) {
|
||
setGoods(goodsRes)
|
||
hasInitialLoadedRef.current = true
|
||
}
|
||
|
||
// 设置默认收货地址
|
||
if (addressRes && addressRes.length > 0) {
|
||
setAddress(addressRes[0])
|
||
}
|
||
|
||
// Load ticket-order history to enforce "address can be modified once per 30 days".
|
||
// If currently locked, force using last ticket-order address (snapshot) to avoid getting stuck with a new default address.
|
||
try {
|
||
const limit = await loadTicketAddressModifyLimit()
|
||
setTicketAddressModifyLimit(limit)
|
||
if (!limit.canModify && limit.lockedAddressId) {
|
||
const locked = await getShopUserAddress(limit.lockedAddressId)
|
||
if (locked?.id) setAddress(locked)
|
||
}
|
||
} catch (e) {
|
||
console.error('加载送水地址修改限制失败:', e)
|
||
setTicketAddressModifyLimit({ loaded: true, canModify: true })
|
||
}
|
||
// 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())
|
||
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(() => {
|
||
// Only prompt when user is allowed to change the ticket delivery address.
|
||
// Otherwise this toast is noisy (they can't fix it within the cooldown window).
|
||
if (!ticketAddressModifyLimit.loaded) return
|
||
if (!ticketAddressModifyLimit.canModify) return
|
||
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,
|
||
ticketAddressModifyLimit.loaded,
|
||
ticketAddressModifyLimit.canModify
|
||
])
|
||
|
||
// When tickets/stock change, clamp quantity into [0..maxQuantity].
|
||
useEffect(() => {
|
||
setQuantity(prev => {
|
||
if (maxQuantity <= 0) return 0
|
||
if (maxQuantity < MIN_START_QTY) return 0
|
||
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY
|
||
return Math.min(prev, maxQuantity)
|
||
})
|
||
}, [maxQuantity])
|
||
|
||
// If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
|
||
useEffect(() => {
|
||
if (!noUsableTickets) 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])
|
||
|
||
// 重新加载数据
|
||
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 || !goods) {
|
||
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>
|
||
{ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && (
|
||
<View className={'pt-1 text-xs text-orange-500 hidden'}>
|
||
送水地址每{ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次
|
||
{ticketAddressModifyLimit.nextAllowedText ? `,${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</Space>
|
||
</Cell>
|
||
)
|
||
}
|
||
{!address && (
|
||
<Cell className={''} onClick={openAddressPage}>
|
||
<Space>
|
||
<Location/>
|
||
添加收货地址
|
||
</Space>
|
||
</Cell>
|
||
)}
|
||
</CellGroup>
|
||
|
||
<CellGroup>
|
||
<Cell
|
||
title={'配送时间'}
|
||
extra={(
|
||
<View className={'flex items-center gap-2'}>
|
||
<View className={'text-gray-900'}>{sendTimeText}</View>
|
||
</View>
|
||
)}
|
||
/>
|
||
</CellGroup>
|
||
|
||
<CellGroup>
|
||
<Cell
|
||
title={'送水数量'}
|
||
description={
|
||
canStartOrder
|
||
? `最低起送 ${MIN_START_QTY} 桶`
|
||
: `最低起送 ${MIN_START_QTY} 桶(当前最多 ${maxQuantity} 桶)`
|
||
}
|
||
extra={(
|
||
<ConfigProvider theme={customTheme}>
|
||
<InputNumber
|
||
value={displayQty}
|
||
min={canStartOrder ? MIN_START_QTY : 0}
|
||
max={canStartOrder ? maxQuantity : 0}
|
||
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) {
|
||
const r = await Taro.showModal({
|
||
title: '暂无可用水票',
|
||
content: '您还没有可用水票,是否前往购买?',
|
||
confirmText: '去购买',
|
||
cancelText: '暂不'
|
||
})
|
||
if (r.confirm) await goBuyTickets()
|
||
return
|
||
}
|
||
setTicketPopupVisible(true)
|
||
}}
|
||
/>
|
||
{noUsableTickets && (
|
||
<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={goBuyTickets}>
|
||
去购买水票
|
||
</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>
|
||
|
||
<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>
|
||
</div>
|
||
<div className={'buy-btn mx-4'}>
|
||
{noUsableTickets ? (
|
||
<Button type="primary" size="large" onClick={goBuyTickets}>
|
||
去购买水票
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="success"
|
||
size="large"
|
||
loading={submitLoading || deliveryRangeChecking}
|
||
disabled={
|
||
deliveryRangeChecking ||
|
||
!address?.id ||
|
||
!addressHasCoords ||
|
||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
|
||
availableTicketTotal <= 0 ||
|
||
!canStartOrder
|
||
}
|
||
onClick={onSubmit}
|
||
>
|
||
{deliveryRangeChecking
|
||
? '校验配送范围...'
|
||
: (!address?.id
|
||
? '请选择地址'
|
||
: (!addressHasCoords
|
||
? '地址缺少定位'
|
||
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false)
|
||
? '不在配送范围'
|
||
: (submitLoading ? '提交中...' : '立即提交')
|
||
)
|
||
)
|
||
)
|
||
}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</View>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default OrderConfirm;
|