From 6f1e0a6a2bad3f4a807a0e79b987e963cc62ca2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Sat, 28 Feb 2026 00:38:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket):=20=E6=B7=BB=E5=8A=A0=E9=80=81?= =?UTF-8?q?=E6=B0=B4=E5=9C=B0=E5=9D=80=E4=BF=AE=E6=94=B9=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 ADDRESS_CHANGE_COOLDOWN_DAYS 常量设置30天修改间隔 - 新增 ticketAddressModifyLimit 状态管理地址修改权限 - 实现 loadTicketAddressModifyLimit 函数查询订单历史判断修改限制 - 添加 openAddressPage 函数控制地址页面跳转逻辑 - 在提交订单时验证地址修改限制并显示提示信息 - 初始化时加载地址修改限制并强制使用锁定地址 - 更新地址单元格点击事件为 openAddressPage 函数 - 添加地址修改限制状态显示到界面 --- src/user/ticket/use.tsx | 221 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 206 insertions(+), 15 deletions(-) diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 4ea2a4d..8f8abb3 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -16,7 +16,7 @@ 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 { listShopUserAddress } from '@/api/shop/shopUserAddress' +import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress' import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model' import './use.scss' import Gap from "@/components/Gap"; @@ -27,6 +27,8 @@ import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/s 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' @@ -34,6 +36,7 @@ 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(null); @@ -97,6 +100,137 @@ const OrderConfirm = () => { return Number.isFinite(id) && id > 0 ? id : undefined }, []) + type TicketAddressModifyLimit = { + loaded: boolean + canModify: boolean + nextAllowedText?: string + lockedAddressId?: number + } + const [ticketAddressModifyLimit, setTicketAddressModifyLimit] = useState({ + loaded: false, + canModify: true, + }) + const ticketAddressModifyLimitPromiseRef = useRef | 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 | null) => { + return parseTime(o?.createTime) || parseTime(o?.updateTime) + } + + const getOrderAddressKey = (o?: Partial | 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 => { + 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 | null) => { if (!t) return 0 const anyT: any = t @@ -199,6 +333,22 @@ const OrderConfirm = () => { 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 => { if (fencesLoadedRef.current) return fences if (fencesPromiseRef.current) return fencesPromiseRef.current @@ -484,6 +634,25 @@ const OrderConfirm = () => { 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 @@ -618,6 +787,20 @@ const OrderConfirm = () => { 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) { @@ -777,13 +960,13 @@ const OrderConfirm = () => { {/* onClick={openStorePopup}*/} {/* />*/} {/**/} - - { - address && ( - Taro.navigateTo({ url: '/user/address/index' })} - > + + { + address && ( + @@ -795,14 +978,22 @@ const OrderConfirm = () => { - {address.name} {address.phone} - - - - ) - } + + {address.name} {address.phone} + {ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && ( + + 送水地址每{ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次 + {ticketAddressModifyLimit.nextAllowedText ? `,${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''} + + )} + + + + + ) + } {!address && ( - Taro.navigateTo({url: '/user/address/index'})}> + 添加收货地址