diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 47393a8..b64407e 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -47,13 +47,25 @@ const STATUS_LABELS: Record = { PENDING_SHIP: '待发货', OUT_WAREHOUSE: '出库中', SHIPPING: '配送中', + SHIPPED: '配送中', PENDING_RECEIVE: '待签收', + DELIVERED: '待签收', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退款中', REFUNDED: '已退款', }; +/** 已付款未完成:可选现场取货并确认收货 */ +const ON_SITE_PICKUP_STATUSES = new Set([ + 'PENDING_SHIP', + 'OUT_WAREHOUSE', + 'SHIPPING', + 'SHIPPED', + 'PENDING_RECEIVE', + 'DELIVERED', +]); + function fullReceiverAddress(order: OrderDetail) { const detail = (order.receiverAddress || '').trim(); const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict] @@ -68,6 +80,8 @@ export default function OrderDetailPage() { const router = useRouter(); const orderId = router.params.id ?? ''; const [order, setOrder] = useState(null); + const [onSitePickup, setOnSitePickup] = useState(false); + const [confirming, setConfirming] = useState(false); useEffect(() => { if (!orderId) return; @@ -76,7 +90,16 @@ export default function OrderDetailPage() { .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); }, [orderId]); - const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId; + const isReship = !!order?.originOrderId; + const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; + const canOnSitePickup = + !!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || ''); + const canConfirmReceive = + !!order && + !isReship && + (['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') || + (onSitePickup && canOnSitePickup)); + const item = order?.items?.[0]; const productName = item?.productName || order?.productName || '杜康商品'; const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1; @@ -111,10 +134,41 @@ export default function OrderDetailPage() { Taro.navigateTo({ url: '/pages/customer-service/index' }); } + async function confirmReceive() { + if (!order || !canConfirmReceive || confirming) return; + + const useOnSite = + onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); + const { confirm } = await Taro.showModal({ + title: useOnSite ? '确认现场取货?' : '确认收货?', + content: useOnSite + ? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。' + : '请确认已收到商品。确认后订单将完成,好客权益可正常使用。', + confirmText: '确认收货', + cancelText: '再想想', + }); + if (!confirm) return; + + setConfirming(true); + try { + const updated = await request(`/trade/orders/${order.id}/confirm-receive`, { + method: 'POST', + data: { onSitePickup: useOnSite }, + }); + setOrder(updated); + setOnSitePickup(false); + toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货'); + } catch (e) { + toast(e instanceof Error ? e.message : '确认收货失败'); + } finally { + setConfirming(false); + } + } + const pageClass = [ 'order-detail-page', order ? 'order-detail-page--with-actions' : '', - canPay ? 'order-detail-page--with-pay' : '', + canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '', ] .filter(Boolean) .join(' '); @@ -171,6 +225,27 @@ export default function OrderDetailPage() { 地址信息待完善 )} + {canOnSitePickup ? ( + + 取货方式 + setOnSitePickup((v) => !v)} + > + + {onSitePickup ? : null} + + + 现场取货 + + 已在活动现场或门店拿到商品时勾选,确认后订单直接完成 + + + + + ) : null} 订单信息 @@ -189,7 +264,11 @@ export default function OrderDetailPage() { {order ? ( - + {isWeapp ? ( ) : null} + {canConfirmReceive ? ( + void confirmReceive()} + > + {confirming ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'} + + ) : null} ) : null} diff --git a/apps/mini-user/src/pages/redeem/index.tsx b/apps/mini-user/src/pages/redeem/index.tsx index 30cb143..b6469d5 100644 --- a/apps/mini-user/src/pages/redeem/index.tsx +++ b/apps/mini-user/src/pages/redeem/index.tsx @@ -17,17 +17,29 @@ function formatMoney(amount: number) { return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -/** 核销金额输入:最多两位小数,禁止非法字符 */ +/** 核销金额输入:最多两位小数;去掉前导 0;禁止非法字符 */ function sanitizeRedeemAmountInput(raw: string): string { - let next = raw.replace(/[^\d.]/g, ''); + let next = String(raw ?? '').replace(/[^\d.]/g, ''); + if (!next) return ''; + const firstDot = next.indexOf('.'); if (firstDot >= 0) { - next = - next.slice(0, firstDot + 1) + next.slice(firstDot + 1).replace(/\./g, ''); - const [intPart, decPart = ''] = next.split('.'); - next = `${intPart}.${decPart.slice(0, 2)}`; + const intRaw = next.slice(0, firstDot).replace(/\D/g, ''); + const decRaw = next + .slice(firstDot + 1) + .replace(/\D/g, '') + .replace(/\./g, '') + .slice(0, 2); + const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0'; + // 正在输入小数点或小数位时保留点 + if (decRaw.length > 0 || next.endsWith('.')) { + return `${intPart}.${decRaw}`; + } + return intPart; } - if (next.startsWith('.')) next = `0${next}`; + + // 纯整数:忽略前导 0(保留单个 0) + next = next.replace(/^0+(?=\d)/, ''); return next; } @@ -37,7 +49,7 @@ export default function RedeemPage() { const initialAmount = router.params.amount ?? ''; const [balance, setBalance] = useState(0); const [couponBalance, setCouponBalance] = useState(null); - const [amount, setAmount] = useState(initialAmount); + const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount)); const [loading, setLoading] = useState(false); const redeemableMax = couponId ? (couponBalance ?? 0) : balance; @@ -67,7 +79,11 @@ export default function RedeemPage() { function fillMaxAmount() { if (redeemableMax < MIN_REDEEM_AMOUNT) return; - setAmount(String(redeemableMax)); + setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2))); + } + + function onAmountChange(raw: string) { + setAmount(sanitizeRedeemAmountInput(raw)); } async function submit() { @@ -119,7 +135,8 @@ export default function RedeemPage() { placeholder="输入核销金额" placeholderClass="redeem-input-placeholder" value={amount} - onInput={(e) => setAmount(sanitizeRedeemAmountInput(e.detail.value))} + onInput={(e) => onAmountChange(e.detail.value)} + onBlur={(e) => onAmountChange(e.detail.value)} style={{ textAlign: 'center' }} /> diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 842c249..089b4cc 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -68,6 +68,63 @@ flex-shrink: 0; } +.order-confirm-submit--disabled { + opacity: 0.55; + pointer-events: none; +} + +.order-pickup-option { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.order-pickup-check { + width: 20px; + height: 20px; + margin-top: 2px; + flex-shrink: 0; + border: 1.5px solid var(--color-outline, #c8c4be); + border-radius: 4px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: #fff; +} + +.order-pickup-check--on { + border-color: var(--color-primary, #8b1a1a); + background: var(--color-primary, #8b1a1a); +} + +.order-pickup-check-mark { + color: #fff; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.order-pickup-copy { + flex: 1; + min-width: 0; +} + +.order-pickup-title { + display: block; + font-size: 15px; + font-weight: 600; + color: var(--color-on-surface); + margin-bottom: 4px; +} + +.order-pickup-desc { + display: block; + font-size: 12px; + line-height: 1.5; + color: var(--color-on-surface-variant, #78716c); +} + .order-card { background: var(--color-card); border-radius: var(--radius-lg); diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 99827c9..942c204 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -64,8 +64,14 @@ export class TradeController { } @Post(':id/confirm-receive') - confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) { - return this.tradeService.confirmReceive(user.actorId, BigInt(id)); + confirmReceive( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body?: { onSitePickup?: boolean }, + ) { + return this.tradeService.confirmReceive(user.actorId, BigInt(id), { + onSitePickup: !!body?.onSitePickup, + }); } @Post(':id/refund-requests') diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 01c0e5b..bb12099 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -467,13 +467,38 @@ export class TradeService { return serializeBigInt(updated); } - async confirmReceive(userId: bigint, orderId: bigint) { + async confirmReceive( + userId: bigint, + orderId: bigint, + opts?: { onSitePickup?: boolean }, + ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); - if (order.status !== 'PENDING_RECEIVE') { + + const onSitePickup = !!opts?.onSitePickup; + const onSiteEligible = [ + 'PENDING_SHIP', + 'OUT_WAREHOUSE', + 'SHIPPING', + 'SHIPPED', + 'PENDING_RECEIVE', + 'DELIVERED', + ]; + if (onSitePickup) { + if (!onSiteEligible.includes(order.status)) { + throw new BadRequestException('当前状态不可现场取货'); + } + } else if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) { throw new BadRequestException('当前状态不可确认收货'); } - await this.applyStatusTransition(order.id, order.status, 'COMPLETED', 'USER'); + + await this.applyStatusTransition( + order.id, + order.status, + 'COMPLETED', + onSitePickup ? 'USER_ON_SITE' : 'USER', + onSitePickup ? '用户现场取货确认收货' : undefined, + ); return this.getOrder(userId, orderId); } @@ -892,6 +917,7 @@ export class TradeService { fromStatus: string, targetStatus: string, operator = 'MOCK', + remark?: string, ) { const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) return; @@ -913,7 +939,7 @@ export class TradeService { await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: orderId }, data: data as never }); if (Object.keys(deliveryData).length) { - await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never }); + await tx.orderDelivery.updateMany({ where: { orderId }, data: deliveryData as never }); } await tx.commonEvent.create({ data: buildOrderStatusEvent({ @@ -921,6 +947,7 @@ export class TradeService { fromStatus: currentStatus, toStatus: targetStatus, operator, + remark, }), }); });