Compare commits
3 Commits
4bbe92d8e8
...
2317c9d973
| Author | SHA1 | Date | |
|---|---|---|---|
| 2317c9d973 | |||
| 260d8086bf | |||
| 78c787d207 |
@@ -15,6 +15,8 @@ export default defineAppConfig({
|
||||
'pages/payment-detail/index',
|
||||
'pages/settings/index',
|
||||
'pages/login/index',
|
||||
'pages/agreement/index',
|
||||
'pages/privacy/index',
|
||||
'pages/change-password/index',
|
||||
'pages/store-info/index',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 购物车悬浮球:右下角,底部避让自定义 tabBar(110rpx + 安全区)
|
||||
.cart-ball {
|
||||
position: fixed;
|
||||
right: 24rpx;
|
||||
bottom: calc(150rpx + env(safe-area-inset-bottom));
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 32rpx 0 8rpx;
|
||||
background: #fff;
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
box-sizing: border-box;
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -16rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
color: #ee0a24;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-left: 16rpx;
|
||||
color: #ee0a24;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Icon } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 购物车悬浮球(首页 / 商品列表页右下角,位于自定义 tabBar 上方):
|
||||
* 展示可购总数量徽标与总金额,点击跳转购物车页;
|
||||
* 未登录或购物车为空(total_count = 0)时隐藏
|
||||
*/
|
||||
export default function CartBall() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
const totalCount = useCartStore(s => s.totalCount)
|
||||
const totalQuantity = useCartStore(s => s.totalQuantity)
|
||||
const totalAmount = useCartStore(s => s.totalAmount)
|
||||
|
||||
const goCart = useCallback(() => {
|
||||
Taro.switchTab({ url: '/pages/cart/index' })
|
||||
}, [])
|
||||
|
||||
if (!token || totalCount <= 0) return null
|
||||
|
||||
return (
|
||||
<View className='cart-ball' onClick={goCart}>
|
||||
<View className='cart-ball__icon'>
|
||||
<Icon name='shopping-cart-o' size='40rpx' color='#ffffff' />
|
||||
<View className='cart-ball__badge'>
|
||||
<Text className='cart-ball__badge-text'>{formatQuantity(totalQuantity)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='cart-ball__amount'>¥{totalAmount}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.cart-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__btn {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
|
||||
&--plus {
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-icon {
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
&__btn--plus &__btn-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__qty {
|
||||
min-width: 64rpx;
|
||||
padding: 0 4rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { addCartApi, deleteCartItemApi, updateCartItemApi } from '@/services/cart'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import type { Product, ProductCartPatch } from '@/types/product'
|
||||
import './index.less'
|
||||
|
||||
/** 加减防抖间隔(ms):连续点击合并为一次提交 */
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
interface CartStepperProps {
|
||||
/** 商品行(使用 id / price / cart_id / cart_quantity) */
|
||||
product: Product
|
||||
/** 服务端确认后的行数据回写(父组件更新列表项的 cart_id/cart_quantity) */
|
||||
onSync: (productId: number, patch: ProductCartPatch) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品行内购物车加减(商品列表 / 首页推荐共用,仅在 cart_quantity > 0 时由父组件渲染):
|
||||
* - 点击即时更新本地数量与悬浮球(乐观展示),防抖后提交服务端
|
||||
* - 不在购物车(cart_id=0)→ POST /mini/cart 合并加购;已存在 → PUT 绝对数量;减到 0 → DELETE
|
||||
* (数量为 0 不能调 PUT,后端校验数量必须 > 0)
|
||||
* - 同一商品的提交串行执行,避免并发导致数量错乱
|
||||
* - 失败回滚本地数量(请求层已 toast),并立即整体校准悬浮球
|
||||
*/
|
||||
export default function CartStepper({ product, onSync }: CartStepperProps) {
|
||||
const applyDelta = useCartStore(s => s.applyDelta)
|
||||
const fetchSummary = useCartStore(s => s.fetchSummary)
|
||||
|
||||
/** 本地编辑数量(乐观值;null = 展示服务端确认值) */
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
/** 最新待提交的目标数量 */
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 提交串行队列 */
|
||||
const chainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
/** 最新商品行快照(供防抖/串行回调读取服务端确认值,避免闭包过期) */
|
||||
const productRef = useRef(product)
|
||||
productRef.current = product
|
||||
|
||||
/** 卸载时清理防抖定时器 */
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
/** 提交目标数量(串行执行;与服务器一致时跳过) */
|
||||
const runSubmit = useCallback(
|
||||
async (target: number) => {
|
||||
const p = productRef.current
|
||||
const confirmed = Number(p.cart_quantity ?? 0)
|
||||
if (target === confirmed) return
|
||||
try {
|
||||
if (target <= 0) {
|
||||
if (p.cart_id) await deleteCartItemApi(p.cart_id)
|
||||
onSync(p.id, { cart_id: 0, cart_quantity: '0.00' })
|
||||
} else if (p.cart_id) {
|
||||
const res = await updateCartItemApi(p.cart_id, target)
|
||||
onSync(p.id, { cart_id: p.cart_id, cart_quantity: res.data.quantity })
|
||||
} else {
|
||||
// 未加购过:POST 合并加购,用返回的行 id 回写本地 cart_id
|
||||
const res = await addCartApi({ product_id: p.id, quantity: target })
|
||||
onSync(p.id, { cart_id: res.data.id, cart_quantity: res.data.quantity })
|
||||
}
|
||||
// 提交期间用户未再改动 → 本地数量落回服务端确认值(onSync 已回写,展示不变)
|
||||
setDraft(prev => (prev === target ? null : prev))
|
||||
} catch {
|
||||
// 失败(超上限等,请求层已 toast):放弃后续目标,回滚本地展示并校准悬浮球
|
||||
targetRef.current = null
|
||||
setDraft(null)
|
||||
fetchSummary().catch(() => {})
|
||||
}
|
||||
},
|
||||
[onSync, fetchSummary],
|
||||
)
|
||||
|
||||
/** 点击加/减:乐观更新本地数量与悬浮球,防抖后入队提交 */
|
||||
const handleTap = useCallback(
|
||||
(delta: 1 | -1) => {
|
||||
const before = draft ?? Number(productRef.current.cart_quantity ?? 0)
|
||||
const after = Math.round(Math.max(0, before + delta) * 100) / 100
|
||||
if (after === before) return
|
||||
setDraft(after)
|
||||
targetRef.current = after
|
||||
// 悬浮球乐观增减(金额按行内售价估算,防抖结束后由服务端汇总校准);
|
||||
// 数量跨过 0 时同步增减商品种数
|
||||
const price = Number(productRef.current.price ?? 0)
|
||||
applyDelta({
|
||||
quantity: delta,
|
||||
amount: Math.round(price * delta * 100) / 100,
|
||||
count: before === 0 && after > 0 ? 1 : before > 0 && after === 0 ? -1 : 0,
|
||||
})
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const target = targetRef.current
|
||||
if (target === null) return
|
||||
targetRef.current = null
|
||||
chainRef.current = chainRef.current.then(() => runSubmit(target))
|
||||
}, DEBOUNCE_MS)
|
||||
},
|
||||
[draft, applyDelta, runSubmit],
|
||||
)
|
||||
|
||||
const shown = draft ?? Number(product.cart_quantity ?? 0)
|
||||
|
||||
return (
|
||||
<View className='cart-stepper' onClick={e => e.stopPropagation()}>
|
||||
<View className='cart-stepper__btn' onClick={() => handleTap(-1)}>
|
||||
<Text className='cart-stepper__btn-icon'>-</Text>
|
||||
</View>
|
||||
<Text className='cart-stepper__qty'>{formatQuantity(shown)}</Text>
|
||||
<View className='cart-stepper__btn cart-stepper__btn--plus' onClick={() => handleTap(1)}>
|
||||
<Text className='cart-stepper__btn-icon'>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default function PriceText({ price, spec, className, mode = 'inline' }: P
|
||||
<View className={`price-text ${className ?? ''}`}>
|
||||
<Text className='price-text__main'>¥{price}</Text>
|
||||
{retail !== null && (
|
||||
<Text className='price-text__retail'>零售价:¥{retail}</Text>
|
||||
<Text className='price-text__retail'>单价:¥{retail}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
@@ -40,7 +40,7 @@ export default function PriceText({ price, spec, className, mode = 'inline' }: P
|
||||
<Text className={className}>
|
||||
¥{price}
|
||||
{retail !== null && (
|
||||
<Text className='price-text__retail price-text__retail--inline'>零售价:¥{retail}</Text>
|
||||
<Text className='price-text__retail price-text__retail--inline'>单价:¥{retail}</Text>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '用户服务协议',
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ========================================
|
||||
协议/政策页面(用户协议、隐私政策共用)
|
||||
======================================== */
|
||||
|
||||
.agreement-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.agreement-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
padding: 32px 40px 80px;
|
||||
|
||||
.doc-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-updated {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.doc-p {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #323233;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 24px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.doc-h2 {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 48px 0 16px;
|
||||
}
|
||||
|
||||
.doc-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import CustomNavBar from '@/components/NavBar'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 用户服务协议
|
||||
* 静态协议文本页,由登录页/设置页进入
|
||||
*/
|
||||
export default function AgreementPage() {
|
||||
return (
|
||||
<View className='agreement-page'>
|
||||
<CustomNavBar title='用户服务协议' />
|
||||
<ScrollView scrollY className='agreement-scroll'>
|
||||
<View className='agreement-content'>
|
||||
<Text className='doc-title'>用户服务协议</Text>
|
||||
<Text className='doc-updated'>更新日期:2026年8月21日 生效日期:2026年8月21日</Text>
|
||||
|
||||
<Text className='doc-p'>
|
||||
欢迎使用「订货采购」小程序(以下简称“本小程序”)。本小程序由平台运营方(以下简称“我们”)为合作门店提供商品订货、订单管理、对账结算等采购服务。请您在使用本小程序前,认真阅读并充分理解本协议全部内容。
|
||||
</Text>
|
||||
<Text className='doc-p doc-bold'>
|
||||
您勾选“我已阅读并同意”并点击登录,即表示您已充分阅读、理解并接受本协议的全部内容,本协议即在您与我们之间产生法律效力。若您不同意本协议的任何内容,请立即停止登录或使用本小程序。
|
||||
</Text>
|
||||
|
||||
<Text className='doc-h2'>一、账号与登录</Text>
|
||||
<Text className='doc-p'>1.1 本小程序面向已与我们建立合作关系的门店用户开放,登录账号及初始密码由商家(供货方)在后台分配,本小程序不提供自助注册功能。</Text>
|
||||
<Text className='doc-p'>1.2 您应妥善保管账号和密码,不得将账号出借、转让或授权他人使用。因您主动泄露密码或遭受他人攻击、诈骗等行为导致的损失,由您自行承担。</Text>
|
||||
<Text className='doc-p'>1.3 如发现账号被他人非法使用或存在安全漏洞,请立即联系客服处理。</Text>
|
||||
<Text className='doc-p'>1.4 您可在登录后通过“我的-设置-修改密码”功能自行修改登录密码。</Text>
|
||||
|
||||
<Text className='doc-h2'>二、服务内容</Text>
|
||||
<Text className='doc-p'>2.1 本小程序为您提供以下服务:商品浏览与搜索、在线下单订货、购物车管理、订单查询与管理、账单查看与对账、在线付款及付款记录查询、门店信息查看、消息通知等。</Text>
|
||||
<Text className='doc-p'>2.2 您理解并同意,商品的价格、库存、配送等信息由商家(供货方)提供并负责,实际交易关系发生在您与商家之间。</Text>
|
||||
<Text className='doc-p'>2.3 我们有权根据业务调整对服务功能进行变更、暂停或终止,并以页面公告等方式通知您。</Text>
|
||||
|
||||
<Text className='doc-h2'>三、用户行为规范</Text>
|
||||
<Text className='doc-p'>3.1 您承诺在使用本小程序过程中遵守国家法律法规,不得利用本小程序从事任何违法违规活动,包括但不限于:发布违法信息、恶意刷单、攻击系统、窃取数据等。</Text>
|
||||
<Text className='doc-p'>3.2 您应保证下单、付款等操作的真实性,并按照与商家的约定及时完成结算。</Text>
|
||||
<Text className='doc-p'>3.3 如您违反本协议约定,我们有权视情节采取警示、限制功能、暂停或终止向您提供服务等措施。</Text>
|
||||
|
||||
<Text className='doc-h2'>四、交易与结算</Text>
|
||||
<Text className='doc-p'>4.1 您通过本小程序提交的订单,经商家确认后生效。订单的履行(发货、配送、退换货等)由商家负责。</Text>
|
||||
<Text className='doc-p'>4.2 账单金额、回款周期等结算规则以您与商家的约定及小程序内展示为准。</Text>
|
||||
<Text className='doc-p'>4.3 付款记录、账单明细等信息可在“账单”及“付款记录”页面查询,请您及时核对;如有异议,请及时联系客服。</Text>
|
||||
|
||||
<Text className='doc-h2'>五、知识产权</Text>
|
||||
<Text className='doc-p'>5.1 本小程序的页面设计、程序代码、商标标识等知识产权归我们或相关权利人所有。未经书面许可,您不得复制、传播、修改或用于任何商业用途。</Text>
|
||||
<Text className='doc-p'>5.2 商品图片、描述等内容由商家提供,相关权利归商家或其权利人所有。</Text>
|
||||
|
||||
<Text className='doc-h2'>六、免责声明</Text>
|
||||
<Text className='doc-p'>6.1 因不可抗力(自然灾害、政府行为、网络故障等)导致服务中断或数据损失的,我们不承担责任,但将尽力减少对您的影响。</Text>
|
||||
<Text className='doc-p'>6.2 因您自身原因(如账号泄露、操作失误、网络环境异常等)造成的损失,由您自行承担。</Text>
|
||||
<Text className='doc-p'>6.3 您与商家之间因商品质量、交付、售后等产生的纠纷,由您与商家协商解决,我们将提供必要的协助。</Text>
|
||||
|
||||
<Text className='doc-h2'>七、协议的变更与终止</Text>
|
||||
<Text className='doc-p'>7.1 我们有权根据法律法规及业务需要修订本协议,修订后的协议将在本页面公示。若您不同意修订后的协议,应停止使用本小程序;继续使用则视为接受修订后的协议。</Text>
|
||||
<Text className='doc-p'>7.2 如您与商家的合作关系终止,我们有权停止或注销您的登录账号。</Text>
|
||||
|
||||
<Text className='doc-h2'>八、法律适用与争议解决</Text>
|
||||
<Text className='doc-p'>8.1 本协议的订立、执行和解释及争议的解决均适用中华人民共和国法律。</Text>
|
||||
<Text className='doc-p'>8.2 因本协议引起的或与本协议有关的任何争议,双方应友好协商解决;协商不成的,任何一方均可向我们所在地有管辖权的人民法院提起诉讼。</Text>
|
||||
|
||||
<Text className='doc-h2'>九、联系我们</Text>
|
||||
<Text className='doc-p'>如您对本协议有任何疑问、意见或建议,可通过小程序内“消息”页面或商家提供的客服渠道与我们联系。</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||
// 底部预留自定义 tabBar(110rpx + 安全区)+ 购物车悬浮球空间,避免内容被遮挡
|
||||
padding-bottom: calc(250rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
|
||||
// ===== 自定义顶部导航栏 =====
|
||||
|
||||
@@ -8,8 +8,10 @@ import { getHomeConfigApi } from '@/services/home'
|
||||
import type { HomeConfig } from '@/services/home'
|
||||
import { getProductListApi } from '@/services/product'
|
||||
import { getProductCover } from '@/types/product'
|
||||
import type { Product } from '@/types/product'
|
||||
import type { Product, ProductCartPatch } from '@/types/product'
|
||||
import PriceText from '@/components/PriceText'
|
||||
import CartBall from '@/components/CartBall'
|
||||
import CartStepper from '@/components/CartStepper'
|
||||
import { formatSpec } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
@@ -41,6 +43,7 @@ function getStatusBarHeight(): number {
|
||||
|
||||
export default function IndexPage() {
|
||||
const addItem = useCartStore(s => s.addItem)
|
||||
const setSummary = useCartStore(s => s.setSummary)
|
||||
|
||||
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
|
||||
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||
@@ -56,15 +59,17 @@ export default function IndexPage() {
|
||||
loadRecommend()
|
||||
})
|
||||
|
||||
/** 首页配置聚合数据 */
|
||||
/** 首页配置聚合数据(响应附带悬浮球汇总) */
|
||||
const loadHomeConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await getHomeConfigApi()
|
||||
setConfig(res.data)
|
||||
// 旧版本后端可能未返回 cart 块
|
||||
if (res.data.cart) setSummary(res.data.cart)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
}
|
||||
}, [])
|
||||
}, [setSummary])
|
||||
|
||||
/** 推荐商品 */
|
||||
const loadRecommend = useCallback(async () => {
|
||||
@@ -112,18 +117,24 @@ export default function IndexPage() {
|
||||
goProduct(keyword.trim())
|
||||
}, [goProduct, keyword])
|
||||
|
||||
/** 快捷加购 */
|
||||
/** 行内加减购确认后回写推荐商品项的购物车字段 */
|
||||
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||
}, [])
|
||||
|
||||
/** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */
|
||||
const handleQuickAdd = useCallback(
|
||||
async (product: Product, e: any) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await addItem(product.id, 1)
|
||||
const res = await addItem(product.id, 1)
|
||||
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||
} catch {
|
||||
// 错误(未设等级价等)已由 request 层 toast
|
||||
}
|
||||
},
|
||||
[addItem],
|
||||
[addItem, handleRowSync],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -261,9 +272,14 @@ export default function IndexPage() {
|
||||
) : (
|
||||
<Text className='product-card__price product-card__price--none'>登陆后查看价格</Text>
|
||||
)}
|
||||
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
|
||||
<Text className='product-card__add-icon'>+</Text>
|
||||
</View>
|
||||
{/* 已加购展示行内加减器,否则展示快捷加购按钮 */}
|
||||
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||
<CartStepper product={product} onSync={handleRowSync} />
|
||||
) : (
|
||||
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
|
||||
<Text className='product-card__add-icon'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -271,6 +287,9 @@ export default function IndexPage() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ========== 购物车悬浮球 ========== */}
|
||||
<CartBall />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 协议文字 ========== */
|
||||
/* ========== 协议勾选区 ========== */
|
||||
.login-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -193,9 +193,35 @@
|
||||
margin-top: 32px;
|
||||
line-height: 1.6;
|
||||
|
||||
.agree-checkbox {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c8c9cc;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--checked {
|
||||
background: #ee0a24;
|
||||
border-color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
.agree-checkbox-tick {
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agree-text {
|
||||
font-size: 24px;
|
||||
color: #c8c9cc;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.agree-link {
|
||||
|
||||
@@ -18,6 +18,8 @@ export default function LoginPage() {
|
||||
/** 登录密码 */
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
/** 是否已阅读并同意协议(默认不勾选,须用户自主勾选后才能登录) */
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
|
||||
/** 返回上一页(无页面栈时回首页) */
|
||||
const goBack = useCallback(() => {
|
||||
@@ -41,6 +43,10 @@ export default function LoginPage() {
|
||||
Taro.showToast({ title: '请输入登录密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!agreed) {
|
||||
Taro.showToast({ title: '请先阅读并勾选同意《用户服务协议》和《隐私政策》', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login({ username: account, password })
|
||||
@@ -51,16 +57,21 @@ export default function LoginPage() {
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [login, submitting, username, password, goBack])
|
||||
}, [login, submitting, username, password, agreed, goBack])
|
||||
|
||||
/** 查看用户协议 */
|
||||
/** 查看用户服务协议 */
|
||||
const handleShowAgreement = useCallback(() => {
|
||||
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
|
||||
Taro.navigateTo({ url: '/pages/agreement/index' })
|
||||
}, [])
|
||||
|
||||
/** 查看隐私政策 */
|
||||
const handleShowPrivacy = useCallback(() => {
|
||||
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
|
||||
Taro.navigateTo({ url: '/pages/privacy/index' })
|
||||
}, [])
|
||||
|
||||
/** 勾选/取消勾选协议 */
|
||||
const toggleAgreed = useCallback(() => {
|
||||
setAgreed(v => !v)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -125,9 +136,15 @@ export default function LoginPage() {
|
||||
</View>
|
||||
|
||||
<View className='login-agreement'>
|
||||
<Text className='agree-text'>登录即代表同意</Text>
|
||||
<View
|
||||
className={`agree-checkbox ${agreed ? 'agree-checkbox--checked' : ''}`}
|
||||
onClick={toggleAgreed}
|
||||
>
|
||||
{agreed && <Text className='agree-checkbox-tick'>✓</Text>}
|
||||
</View>
|
||||
<Text className='agree-text'>我已阅读并同意</Text>
|
||||
<Text className='agree-link' onClick={handleShowAgreement}>
|
||||
《用户协议》
|
||||
《用户服务协议》
|
||||
</Text>
|
||||
<Text className='agree-text'>和</Text>
|
||||
<Text className='agree-link' onClick={handleShowPrivacy}>
|
||||
|
||||
@@ -2,15 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import { Empty } from '@antmjs/vantui'
|
||||
import { getPaymentDetailApi, PAY_METHOD_NAMES, PAY_STATUS_NAMES } from '@/services/payment'
|
||||
import {
|
||||
getPaymentDetailApi,
|
||||
getPayStatusName,
|
||||
PAY_METHOD_NAMES,
|
||||
queryOnlinePaymentApi,
|
||||
} from '@/services/payment'
|
||||
import { resolveFileUrl } from '@/utils/format'
|
||||
import type { PaymentDetail } from '@/services/payment'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 支付详情页
|
||||
* 支付信息 + 汇款凭证(可预览)+ 合并付款的账单(可下钻账单详情)
|
||||
* 已拒绝时底部提供「重新发起付款」(账单已由后台释放,可重新合并提交)
|
||||
* 线下凭证单:支付信息 + 汇款凭证(可预览)+ 合并付款的账单(可下钻账单详情),审核拒绝后可重新发起付款
|
||||
* 在线支付单:无凭证,待支付时可「刷新支付结果」主动同步网关结果(后台通知延迟/丢失时的兜底)
|
||||
*/
|
||||
export default function PaymentDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -18,16 +23,25 @@ export default function PaymentDetailPage() {
|
||||
|
||||
const [detail, setDetail] = useState<PaymentDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getPaymentDetailApi(id)
|
||||
.then(res => setDetail(res.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
try {
|
||||
const res = await getPaymentDetailApi(id)
|
||||
setDetail(res.data)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail()
|
||||
}, [loadDetail])
|
||||
|
||||
/** 预览凭证图片 */
|
||||
const previewVoucher = useCallback((current: string) => {
|
||||
const urls = (detail?.payment.voucher_urls ?? []).map(resolveFileUrl)
|
||||
@@ -39,13 +53,35 @@ export default function PaymentDetailPage() {
|
||||
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${billId}` })
|
||||
}, [])
|
||||
|
||||
/** 已拒绝 → 携带本组账单重新发起付款 */
|
||||
/** 已拒绝 / 支付失败 → 携带本组账单重新发起付款(账单已由后台释放) */
|
||||
const handleRepay = useCallback(() => {
|
||||
if (!detail) return
|
||||
const ids = detail.bills.map(b => b.id).join(',')
|
||||
Taro.redirectTo({ url: `/pages/payment/index?ids=${ids}` })
|
||||
}, [detail])
|
||||
|
||||
/** 在线支付待支付 → 主动查询网关同步结果(已支付则后端立即结账),随后刷新详情 */
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!detail || syncing) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const res = await queryOnlinePaymentApi(detail.payment.payment_no)
|
||||
if (res.data.status === 1) {
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
loadDetail()
|
||||
} else if (res.data.status === 2) {
|
||||
Taro.showToast({ title: '支付失败,账单已释放', icon: 'none' })
|
||||
loadDetail()
|
||||
} else {
|
||||
Taro.showToast({ title: '暂未查询到支付结果,请稍后再试', icon: 'none' })
|
||||
}
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [detail, syncing, loadDetail])
|
||||
|
||||
if (loading && !detail) {
|
||||
return <View className='pay-detail'><Empty description='加载中...' /></View>
|
||||
}
|
||||
@@ -55,6 +91,8 @@ export default function PaymentDetailPage() {
|
||||
|
||||
const { payment, bills } = detail
|
||||
const vouchers = payment.voucher_urls.map(resolveFileUrl)
|
||||
/** 在线支付单(旺铺网关):状态语义与线下凭证单不同,无凭证 */
|
||||
const isOnline = payment.pay_type === 2
|
||||
|
||||
return (
|
||||
<View className={`pay-detail ${payment.status === 2 ? 'pay-detail--reject' : ''}`}>
|
||||
@@ -63,16 +101,23 @@ export default function PaymentDetailPage() {
|
||||
<View className='pay-card__header'>
|
||||
<Text className='pay-card__no'>{payment.payment_no}</Text>
|
||||
<Text className={`pay-card__status pay-card__status--${payment.status}`}>
|
||||
{PAY_STATUS_NAMES[payment.status]}
|
||||
{getPayStatusName(payment)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='pay-card__amount'>¥{payment.amount}</Text>
|
||||
{payment.status === 0 && (
|
||||
{payment.status === 0 && !isOnline && (
|
||||
<Text className='pay-card__tip'>付款申请已提交,商家审核通过后账单将置为已支付</Text>
|
||||
)}
|
||||
{payment.status === 0 && isOnline && (
|
||||
<Text className='pay-card__tip'>
|
||||
账单已锁定,等待支付结果确认;如已完成支付,可点击下方「刷新支付结果」
|
||||
</Text>
|
||||
)}
|
||||
{payment.status === 2 && (
|
||||
<Text className='pay-card__tip pay-card__tip--reject'>
|
||||
审核未通过{payment.audit_remark ? `:${payment.audit_remark}` : ''},账单已释放,可重新发起付款
|
||||
{isOnline
|
||||
? '支付失败,账单已释放,可重新发起付款'
|
||||
: `审核未通过${payment.audit_remark ? `:${payment.audit_remark}` : ''},账单已释放,可重新发起付款`}
|
||||
</Text>
|
||||
)}
|
||||
<View className='pay-card__row'>
|
||||
@@ -89,6 +134,18 @@ export default function PaymentDetailPage() {
|
||||
<Text className='pay-card__value'>{payment.audited_at}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isOnline && payment.paid_at && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>支付时间</Text>
|
||||
<Text className='pay-card__value'>{payment.paid_at}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isOnline && payment.trade_no && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>交易单号</Text>
|
||||
<Text className='pay-card__value'>{payment.trade_no}</Text>
|
||||
</View>
|
||||
)}
|
||||
{payment.remark && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>付款备注</Text>
|
||||
@@ -97,22 +154,24 @@ export default function PaymentDetailPage() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ===== 汇款凭证 ===== */}
|
||||
<View className='pay-card'>
|
||||
<Text className='pay-section__title'>汇款凭证({vouchers.length})</Text>
|
||||
<View className='pay-vouchers'>
|
||||
{vouchers.map((url, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className='pay-vouchers__img'
|
||||
src={url}
|
||||
mode='aspectFill'
|
||||
onClick={() => previewVoucher(url)}
|
||||
/>
|
||||
))}
|
||||
{/* ===== 汇款凭证(在线支付单无凭证) ===== */}
|
||||
{!isOnline && (
|
||||
<View className='pay-card'>
|
||||
<Text className='pay-section__title'>汇款凭证({vouchers.length})</Text>
|
||||
<View className='pay-vouchers'>
|
||||
{vouchers.map((url, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className='pay-vouchers__img'
|
||||
src={url}
|
||||
mode='aspectFill'
|
||||
onClick={() => previewVoucher(url)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{vouchers.length === 0 && <Empty description='暂无凭证图片' />}
|
||||
</View>
|
||||
{vouchers.length === 0 && <Empty description='暂无凭证图片' />}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ===== 合并账单 ===== */}
|
||||
<View className='pay-card'>
|
||||
@@ -132,12 +191,21 @@ export default function PaymentDetailPage() {
|
||||
{bills.length === 0 && <Empty description='暂无关联账单' />}
|
||||
</View>
|
||||
|
||||
{/* ===== 已拒绝 → 重新付款 ===== */}
|
||||
{/* ===== 已拒绝 / 支付失败 → 重新付款 ===== */}
|
||||
{payment.status === 2 && (
|
||||
<View className='pay-bar'>
|
||||
<View className='pay-bar__btn' onClick={handleRepay}>重新发起付款</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ===== 在线支付待支付 → 主动同步支付结果 ===== */}
|
||||
{isOnline && payment.status === 0 && (
|
||||
<View className='pay-bar'>
|
||||
<View className='pay-bar__btn' onClick={handleSync}>
|
||||
{syncing ? '查询中...' : '刷新支付结果'}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { Empty } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import { getPaymentListApi, PAY_METHOD_NAMES, PAY_STATUS_NAMES } from '@/services/payment'
|
||||
import { getPaymentListApi, getPayStatusName, PAY_METHOD_NAMES } from '@/services/payment'
|
||||
import type { Payment, PayStatus } from '@/services/payment'
|
||||
import './index.less'
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function PaymentRecordsPage() {
|
||||
<View className='payment-item__header'>
|
||||
<Text className='payment-item__no'>{record.payment_no}</Text>
|
||||
<Text className={`payment-item__status payment-item__status--${record.status}`}>
|
||||
{PAY_STATUS_NAMES[record.status]}
|
||||
{getPayStatusName(record)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='payment-item__body'>
|
||||
@@ -128,7 +128,7 @@ export default function PaymentRecordsPage() {
|
||||
<Text className='payment-item__bills'>合并 {record.bills_count ?? 0} 张账单</Text>
|
||||
</View>
|
||||
</View>
|
||||
{record.status === 2 && !!record.audit_remark && (
|
||||
{record.status === 2 && record.pay_type !== 2 && !!record.audit_remark && (
|
||||
<Text className='payment-item__reject'>拒绝原因:{record.audit_remark}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
+135
-36
@@ -4,7 +4,7 @@ import { View, Text, Image, Textarea } from '@tarojs/components'
|
||||
import { Empty, Icon } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import { getBillListApi } from '@/services/bill'
|
||||
import { createPaymentApi, getPaymentConfigApi } from '@/services/payment'
|
||||
import { createOnlinePaymentApi, createPaymentApi, getPaymentConfigApi, queryOnlinePaymentApi } from '@/services/payment'
|
||||
import { chooseAndUploadImages } from '@/utils/upload'
|
||||
import { resolveFileUrl } from '@/utils/format'
|
||||
import type { Bill } from '@/services/bill'
|
||||
@@ -17,8 +17,14 @@ const PAGE_SIZE = 20
|
||||
/** 凭证最多上传张数 */
|
||||
const MAX_VOUCHERS = 3
|
||||
|
||||
/** 支付方式选项 */
|
||||
/** 在线支付(调起 wx.requestPayment)仅微信小程序支持 */
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp'
|
||||
|
||||
/** 支付方式选项(在线支付仅小程序端展示,排在最前) */
|
||||
const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc: string }> = [
|
||||
...(IS_WEAPP
|
||||
? [{ value: 4 as PayMethod, label: '微信在线支付', icon: 'wechat', desc: '小程序内直接付款,免上传凭证' }]
|
||||
: []),
|
||||
{ value: 1, label: '微信支付', icon: 'wechat', desc: '扫码完成转账' },
|
||||
{ value: 2, label: '支付宝', icon: 'alipay', desc: '扫码完成转账' },
|
||||
{ value: 3, label: '对公汇款', icon: 'credit-pay', desc: '银行转账至对公账户' },
|
||||
@@ -26,7 +32,9 @@ const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc:
|
||||
|
||||
/**
|
||||
* 发起付款页(合并付款)
|
||||
* 选择本店可付款账单(?payable=1)→ 选择支付方式(展示收款码 / 对公账户)→ 上传汇款凭证 → 提交,后台审核
|
||||
* 选择本店可付款账单(?payable=1)→ 选择支付方式 → 提交:
|
||||
* - 在线支付(仅小程序):wx.login 取 code → 后端经旺铺网关下单 → 调起微信支付 → 主动查询同步结果(回调兜底)
|
||||
* - 线下凭证:展示收款码 / 对公账户 → 上传汇款凭证 → 提交,后台审核
|
||||
* 支持 ?ids=1,2 预选账单(账单详情页"去付款"跳转)
|
||||
*/
|
||||
export default function PaymentPage() {
|
||||
@@ -50,12 +58,15 @@ export default function PaymentPage() {
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
const [config, setConfig] = useState<PaymentConfig | null>(null)
|
||||
const [payMethod, setPayMethod] = useState<PayMethod>(1)
|
||||
const [payMethod, setPayMethod] = useState<PayMethod>(IS_WEAPP ? 4 : 1)
|
||||
const [vouchers, setVouchers] = useState<UploadedFile[]>([])
|
||||
const [remark, setRemark] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
/** 在线支付(旺铺网关 JSAPI):免凭证,调起微信支付 */
|
||||
const isOnline = payMethod === 4
|
||||
|
||||
/** 拉取可付款账单(首次加载应用路由预选) */
|
||||
const loadBills = useCallback(
|
||||
async (pageNum: number, reset: boolean) => {
|
||||
@@ -152,8 +163,8 @@ export default function PaymentPage() {
|
||||
Taro.setClipboardData({ data: config.bank_info })
|
||||
}, [config])
|
||||
|
||||
/** 提交付款申请 */
|
||||
const handleSubmit = useCallback(async () => {
|
||||
/** 提交线下凭证付款申请(后台审核) */
|
||||
const handleVoucherSubmit = useCallback(async () => {
|
||||
if (submitting) return
|
||||
if (selectedIds.length === 0) {
|
||||
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
|
||||
@@ -183,8 +194,94 @@ export default function PaymentPage() {
|
||||
}
|
||||
}, [submitting, selectedIds, vouchers, payMethod, remark, loadBills])
|
||||
|
||||
/**
|
||||
* 在线支付:wx.login 取 code → 后端经旺铺网关下单 → 调起微信支付 → 主动查询同步结果
|
||||
* 无论支付成功/取消都跳转支付详情(待支付单可在详情页刷新同步结果)
|
||||
*/
|
||||
const handleOnlinePay = useCallback(async () => {
|
||||
if (submitting) return
|
||||
if (!IS_WEAPP) {
|
||||
Taro.showToast({ title: '请在微信小程序中使用在线支付', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (selectedIds.length === 0) {
|
||||
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 1. 获取微信登录凭证(后端换付款人 openid)
|
||||
const { code } = await Taro.login()
|
||||
if (!code) {
|
||||
Taro.showToast({ title: '微信登录失败,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 2. 后端下单(创建支付单并锁定账单)
|
||||
const res = await createOnlinePaymentApi({
|
||||
bill_ids: selectedIds,
|
||||
code,
|
||||
remark: remark.trim() || undefined,
|
||||
})
|
||||
const { id, payment_no, pay_params } = res.data
|
||||
// 3. 调起微信支付(pay_params 为网关透传的调起参数)
|
||||
try {
|
||||
await Taro.requestPayment({
|
||||
timeStamp: String(pay_params.timeStamp || ''),
|
||||
nonceStr: String(pay_params.nonceStr || ''),
|
||||
package: String(pay_params.package || ''),
|
||||
signType: (pay_params.signType || 'RSA') as 'MD5' | 'HMAC-SHA256' | 'RSA',
|
||||
paySign: String(pay_params.paySign || ''),
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 用户取消或调起失败:账单仍锁定在支付单中,进详情页可刷新同步/稍后处理
|
||||
const errMsg = e?.errMsg || ''
|
||||
Taro.showToast({
|
||||
title: errMsg.includes('cancel') ? '已取消支付' : '支付调起失败,请稍后重试',
|
||||
icon: 'none',
|
||||
})
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
// 4. 主动查询同步结果(网关后台通知延迟/丢失时的兜底结账)
|
||||
let paid = false
|
||||
try {
|
||||
const q = await queryOnlinePaymentApi(payment_no)
|
||||
paid = q.data.status === 1
|
||||
} catch {
|
||||
// 查询失败不阻断,进详情页可手动刷新
|
||||
}
|
||||
Taro.showToast({ title: paid ? '支付成功' : '支付结果确认中', icon: paid ? 'success' : 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
} catch {
|
||||
// 下单失败:账单可能已被其他端付款/锁定,刷新列表
|
||||
loadBills(1, true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [submitting, selectedIds, remark, loadBills])
|
||||
|
||||
/** 提交入口:按支付方式分发 */
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isOnline) {
|
||||
handleOnlinePay()
|
||||
} else {
|
||||
handleVoucherSubmit()
|
||||
}
|
||||
}, [isOnline, handleOnlinePay, handleVoucherSubmit])
|
||||
|
||||
/** 当前支付方式的收款展示 */
|
||||
const renderMethodContent = () => {
|
||||
if (isOnline) {
|
||||
return (
|
||||
<Text className='pay-method__empty'>
|
||||
确认支付后将调起微信支付,支付成功后账单自动结清
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (payMethod === 3) {
|
||||
return config?.bank_info ? (
|
||||
<View className='pay-method__content'>
|
||||
@@ -284,37 +381,39 @@ export default function PaymentPage() {
|
||||
{renderMethodContent()}
|
||||
</View>
|
||||
|
||||
{/* ========== 汇款凭证 ========== */}
|
||||
<View className='pay-section'>
|
||||
<View className='pay-section__header'>
|
||||
<Text className='pay-section__title'>汇款凭证</Text>
|
||||
<Text className='pay-section__hint'>转账截图或回单,最多 {MAX_VOUCHERS} 张</Text>
|
||||
</View>
|
||||
<View className='pay-vouchers'>
|
||||
{vouchers.map((v, i) => {
|
||||
const url = resolveFileUrl(v.url)
|
||||
return (
|
||||
<View key={v.id} className='pay-voucher'>
|
||||
<Image
|
||||
className='pay-voucher__img'
|
||||
src={url}
|
||||
mode='aspectFill'
|
||||
onClick={() => previewImage(vouchers.map(x => resolveFileUrl(x.url)), url)}
|
||||
/>
|
||||
<View className='pay-voucher__del' onClick={() => handleRemoveVoucher(i)}>
|
||||
<Icon name='cross' size={12} color='#fff' />
|
||||
{/* ========== 汇款凭证(在线支付免凭证) ========== */}
|
||||
{!isOnline && (
|
||||
<View className='pay-section'>
|
||||
<View className='pay-section__header'>
|
||||
<Text className='pay-section__title'>汇款凭证</Text>
|
||||
<Text className='pay-section__hint'>转账截图或回单,最多 {MAX_VOUCHERS} 张</Text>
|
||||
</View>
|
||||
<View className='pay-vouchers'>
|
||||
{vouchers.map((v, i) => {
|
||||
const url = resolveFileUrl(v.url)
|
||||
return (
|
||||
<View key={v.id} className='pay-voucher'>
|
||||
<Image
|
||||
className='pay-voucher__img'
|
||||
src={url}
|
||||
mode='aspectFill'
|
||||
onClick={() => previewImage(vouchers.map(x => resolveFileUrl(x.url)), url)}
|
||||
/>
|
||||
<View className='pay-voucher__del' onClick={() => handleRemoveVoucher(i)}>
|
||||
<Icon name='cross' size={12} color='#fff' />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{vouchers.length < MAX_VOUCHERS && (
|
||||
<View className='pay-voucher pay-voucher--add' onClick={handleAddVoucher}>
|
||||
<Icon name={uploading ? 'more' : 'plus'} size={24} color='#969799' />
|
||||
<Text className='pay-voucher__add-text'>{uploading ? '上传中' : '上传凭证'}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{vouchers.length < MAX_VOUCHERS && (
|
||||
<View className='pay-voucher pay-voucher--add' onClick={handleAddVoucher}>
|
||||
<Icon name={uploading ? 'more' : 'plus'} size={24} color='#969799' />
|
||||
<Text className='pay-voucher__add-text'>{uploading ? '上传中' : '上传凭证'}</Text>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 备注 ========== */}
|
||||
<View className='pay-section'>
|
||||
@@ -323,7 +422,7 @@ export default function PaymentPage() {
|
||||
className='pay-remark'
|
||||
value={remark}
|
||||
maxlength={255}
|
||||
placeholder='如:汇款人姓名、转账时间等'
|
||||
placeholder={isOnline ? '可填写付款说明' : '如:汇款人姓名、转账时间等'}
|
||||
onInput={e => setRemark(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
@@ -339,7 +438,7 @@ export default function PaymentPage() {
|
||||
className={`pay-bar__btn ${selectedIds.length === 0 || submitting ? 'disabled' : ''}`}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? '提交中...' : '提交付款'}
|
||||
{submitting ? (isOnline ? '支付中...' : '提交中...') : isOnline ? '立即支付' : '提交付款'}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '隐私政策',
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ========================================
|
||||
隐私政策页面(与用户协议共用样式)
|
||||
======================================== */
|
||||
|
||||
.privacy-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.privacy-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.privacy-content {
|
||||
padding: 32px 40px 80px;
|
||||
|
||||
.doc-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-updated {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.doc-p {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #323233;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 24px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.doc-h2 {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 48px 0 16px;
|
||||
}
|
||||
|
||||
.doc-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import CustomNavBar from '@/components/NavBar'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 隐私政策
|
||||
* 静态政策文本页,由登录页/设置页进入
|
||||
*/
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<View className='privacy-page'>
|
||||
<CustomNavBar title='隐私政策' />
|
||||
<ScrollView scrollY className='privacy-scroll'>
|
||||
<View className='privacy-content'>
|
||||
<Text className='doc-title'>隐私政策</Text>
|
||||
<Text className='doc-updated'>更新日期:2026年8月21日 生效日期:2026年8月21日</Text>
|
||||
|
||||
<Text className='doc-p'>
|
||||
「订货采购」小程序(以下简称“本小程序”)由平台运营方(以下简称“我们”)运营。我们深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最小必要原则、确保安全原则、主体参与原则、公开透明原则等。
|
||||
</Text>
|
||||
<Text className='doc-p doc-bold'>
|
||||
请您在使用本小程序前,仔细阅读并充分理解本政策全部内容。您勾选“我已阅读并同意”并点击登录,即表示您同意我们按照本政策收集、使用、存储和共享您的相关信息。若您不同意本政策的任何内容,您可以选择不使用本小程序。
|
||||
</Text>
|
||||
|
||||
<Text className='doc-h2'>一、我们收集的信息及用途</Text>
|
||||
<Text className='doc-p'>为向您提供订货采购相关服务,我们会在以下场景收集和使用您的信息:</Text>
|
||||
<Text className='doc-p'>1.1 账号登录信息:当您使用账号密码登录时,我们会收集您的登录账号、密码(加密传输与存储),用于验证您的身份并完成登录。</Text>
|
||||
<Text className='doc-p'>1.2 门店与联系人信息:为完成订单配送与结算,我们会使用商家在后台录入的门店名称、门店地址、联系人、联系电话等信息,用于订单配送、账单对账及售后服务。</Text>
|
||||
<Text className='doc-p'>1.3 交易信息:当您下单、付款时,我们会收集您的订单信息(商品、数量、金额)、账单信息、付款记录等,用于订单履行、对账结算与售后处理。</Text>
|
||||
<Text className='doc-p'>1.4 设备与日志信息:为保障服务安全稳定运行,我们可能会收集您的设备型号、操作系统、网络状态、操作日志等信息,用于故障排查、安全风控与服务优化。</Text>
|
||||
|
||||
<Text className='doc-h2'>二、我们如何使用信息</Text>
|
||||
<Text className='doc-p'>2.1 我们仅将收集的信息用于实现本小程序的核心功能,包括:身份验证、商品展示与下单、订单管理、账单对账、付款结算、消息通知、客户服务等。</Text>
|
||||
<Text className='doc-p'>2.2 我们不会将您的个人信息用于与上述功能无关的用途;如超出原目的使用您的信息,我们会再次征得您的明示同意。</Text>
|
||||
|
||||
<Text className='doc-h2'>三、信息的共享、转让与公开披露</Text>
|
||||
<Text className='doc-p'>3.1 我们不会向任何无关第三方出售您的个人信息。</Text>
|
||||
<Text className='doc-p'>3.2 为完成订单履约,我们会将配送所需的信息(门店名称、地址、联系人、电话、订单明细)提供给为您提供商品的商家及配送服务方。</Text>
|
||||
<Text className='doc-p'>3.3 基于法律规定、司法机关或行政机关的合法要求,我们可能会披露您的相关信息。</Text>
|
||||
|
||||
<Text className='doc-h2'>四、信息的存储与保护</Text>
|
||||
<Text className='doc-p'>4.1 您的个人信息存储于中华人民共和国境内。我们仅在为您提供服务所必需的期间内保留您的信息。</Text>
|
||||
<Text className='doc-p'>4.2 我们采用加密传输、访问控制等安全技术和管理措施保护您的信息,防止信息遭到未经授权的访问、披露、使用或损毁。</Text>
|
||||
<Text className='doc-p'>4.3 请您妥善保管账号密码。如发生个人信息安全事件,我们将按照法律法规要求及时告知您并向主管部门报告。</Text>
|
||||
|
||||
<Text className='doc-h2'>五、您的权利</Text>
|
||||
<Text className='doc-p'>5.1 查询与更正:您可在小程序内查询您的订单、账单、付款记录及门店信息;如信息有误,可联系客服更正。</Text>
|
||||
<Text className='doc-p'>5.2 密码管理:您可通过“我的-设置-修改密码”功能自行修改登录密码。</Text>
|
||||
<Text className='doc-p'>5.3 账号注销:如您与商家的合作关系终止,可申请注销账号;账号注销后,我们将删除或匿名化处理您的个人信息,法律法规另有规定的除外。</Text>
|
||||
<Text className='doc-p'>5.4 撤回同意:您可以通过停止使用本小程序的方式撤回对您个人信息收集使用的授权。</Text>
|
||||
|
||||
<Text className='doc-h2'>六、未成年人保护</Text>
|
||||
<Text className='doc-p'>本小程序面向从事经营活动的门店用户,不面向未成年人提供服务。如您为未成年人,请勿使用本小程序。</Text>
|
||||
|
||||
<Text className='doc-h2'>七、本政策的更新</Text>
|
||||
<Text className='doc-p'>我们可能适时修订本政策。政策更新后,我们会在本页面公示更新内容。若您不同意更新后的政策,应停止使用本小程序;继续使用即视为您接受更新后的政策。</Text>
|
||||
|
||||
<Text className='doc-h2'>八、联系我们</Text>
|
||||
<Text className='doc-p'>如您对本政策或您的个人信息处理有任何疑问、意见或投诉,可通过小程序内“消息”页面或商家提供的客服渠道与我们联系,我们将尽快予以答复。</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -96,7 +96,8 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 20rpx 20rpx 40rpx;
|
||||
// 底部留白避免最后一行被购物车悬浮球遮挡
|
||||
padding: 20rpx 20rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
+35
-15
@@ -6,8 +6,10 @@ import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
||||
import type { ProductListParams } from '@/services/product'
|
||||
import { getProductCover } from '@/types/product'
|
||||
import type { Category, Product } from '@/types/product'
|
||||
import type { Category, Product, ProductCartPatch } from '@/types/product'
|
||||
import PriceText from '@/components/PriceText'
|
||||
import CartBall from '@/components/CartBall'
|
||||
import CartStepper from '@/components/CartStepper'
|
||||
import { formatSpec } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
@@ -18,6 +20,7 @@ const PENDING_KEYWORD_KEY = 'product_keyword'
|
||||
|
||||
export default function ProductPage() {
|
||||
const addItem = useCartStore(s => s.addItem)
|
||||
const setSummary = useCartStore(s => s.setSummary)
|
||||
|
||||
/** 分类树 */
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -70,10 +73,12 @@ export default function ProductPage() {
|
||||
if (keyword) params.keyword = keyword
|
||||
const res = await getProductListApi(params)
|
||||
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
||||
const { data, total: totalCount } = res.data
|
||||
const { data, total: totalCount, cart } = res.data
|
||||
setProducts(prev => (reset ? data : [...prev, ...data]))
|
||||
setPage(pageNum)
|
||||
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
||||
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
|
||||
if (cart) setSummary(cart)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
@@ -83,7 +88,7 @@ export default function ProductPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[effectiveCategoryId, searchKey],
|
||||
[effectiveCategoryId, searchKey, setSummary],
|
||||
)
|
||||
|
||||
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
|
||||
@@ -193,17 +198,23 @@ export default function ProductPage() {
|
||||
setShowPopup(true)
|
||||
}, [])
|
||||
|
||||
/** 行内加减购确认后回写列表项的购物车字段 */
|
||||
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||
}, [])
|
||||
|
||||
/** 跳转商品详情 */
|
||||
const goDetail = useCallback((id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
|
||||
}, [])
|
||||
|
||||
/** 确认加购 */
|
||||
/** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */
|
||||
const handleConfirmAdd = useCallback(async () => {
|
||||
if (!current || addingRef.current) return
|
||||
addingRef.current = true
|
||||
try {
|
||||
await addItem(current.id, qty)
|
||||
const res = await addItem(current.id, qty)
|
||||
handleRowSync(current.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||
setShowPopup(false)
|
||||
} catch {
|
||||
@@ -211,7 +222,7 @@ export default function ProductPage() {
|
||||
} finally {
|
||||
addingRef.current = false
|
||||
}
|
||||
}, [current, qty, addItem])
|
||||
}, [current, qty, addItem, handleRowSync])
|
||||
|
||||
return (
|
||||
<View className='product-page'>
|
||||
@@ -303,15 +314,20 @@ export default function ProductPage() {
|
||||
) : (
|
||||
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
||||
)}
|
||||
<View
|
||||
className='product-item__add'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleAddTap(product)
|
||||
}}
|
||||
>
|
||||
<Text className='product-item__add-icon'>+</Text>
|
||||
</View>
|
||||
{/* 已加购展示行内加减器,否则展示加购按钮(点击开弹层选数量) */}
|
||||
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||
<CartStepper product={product} onSync={handleRowSync} />
|
||||
) : (
|
||||
<View
|
||||
className='product-item__add'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleAddTap(product)
|
||||
}}
|
||||
>
|
||||
<Text className='product-item__add-icon'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -323,6 +339,7 @@ export default function ProductPage() {
|
||||
{finished && products.length > 0 && (
|
||||
<View className='product-loading'><Text>没有更多了</Text></View>
|
||||
)}
|
||||
<View style={{ height: 68 }}></View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -376,6 +393,9 @@ export default function ProductPage() {
|
||||
</View>
|
||||
)}
|
||||
</Popup>
|
||||
|
||||
{/* ========== 购物车悬浮球 ========== */}
|
||||
<CartBall />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ export default function SettingsPage() {
|
||||
<Text className='setting-cell__label'>清除缓存</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
<View className='setting-cell' onClick={() => handlePlaceholder('用户协议')}>
|
||||
<Text className='setting-cell__label'>用户协议</Text>
|
||||
<View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index' })}>
|
||||
<Text className='setting-cell__label'>用户服务协议</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
<View className='setting-cell' onClick={() => handlePlaceholder('隐私政策')}>
|
||||
<View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/privacy/index' })}>
|
||||
<Text className='setting-cell__label'>隐私政策</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { del, get, post, put } from '@/utils/request'
|
||||
import type { CartData } from '@/types/cart'
|
||||
import type { CartData, CartSummary } from '@/types/cart'
|
||||
|
||||
/** 加购 / 改数量返回 */
|
||||
export interface CartMutationResult {
|
||||
@@ -17,6 +17,11 @@ export function getCartApi() {
|
||||
return get<CartData>('/mini/cart')
|
||||
}
|
||||
|
||||
/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */
|
||||
export function getCartSummaryApi() {
|
||||
return get<CartSummary>('/mini/cart/summary')
|
||||
}
|
||||
|
||||
/** 修改数量:PUT /mini/cart/{id} */
|
||||
export function updateCartItemApi(id: number, quantity: number) {
|
||||
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get } from '@/utils/request'
|
||||
import type { CartSummary } from '@/types/cart'
|
||||
|
||||
/** 首页轮播图项 */
|
||||
export interface HomeBanner {
|
||||
@@ -36,6 +37,8 @@ export interface HomeConfig {
|
||||
banners: HomeBanner[]
|
||||
navs: HomeNav[]
|
||||
promos: HomePromo[]
|
||||
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||
cart?: CartSummary
|
||||
}
|
||||
|
||||
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||
|
||||
+73
-6
@@ -1,16 +1,20 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
|
||||
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 */
|
||||
export type PayMethod = 1 | 2 | 3
|
||||
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 / 4 旺铺支付(小程序在线支付) */
|
||||
export type PayMethod = 1 | 2 | 3 | 4
|
||||
|
||||
export const PAY_METHOD_NAMES: Record<PayMethod, string> = {
|
||||
1: '微信支付',
|
||||
2: '支付宝',
|
||||
3: '对公汇款',
|
||||
4: '微信在线支付',
|
||||
}
|
||||
|
||||
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝 */
|
||||
/** 支付类型:1 线下凭证支付 / 2 在线支付(旧数据可能缺省,缺省按线下处理) */
|
||||
export type PayType = 1 | 2
|
||||
|
||||
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝(线下凭证支付单语义) */
|
||||
export type PayStatus = 0 | 1 | 2
|
||||
|
||||
export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
|
||||
@@ -19,6 +23,20 @@ export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
|
||||
2: '已拒绝',
|
||||
}
|
||||
|
||||
/** 在线支付状态:0 待支付 / 1 支付成功 / 2 支付失败(与线下同字段,按 pay_type 区分语义) */
|
||||
export type OnlinePayStatus = 0 | 1 | 2
|
||||
|
||||
export const ONLINE_PAY_STATUS_NAMES: Record<OnlinePayStatus, string> = {
|
||||
0: '待支付',
|
||||
1: '支付成功',
|
||||
2: '支付失败',
|
||||
}
|
||||
|
||||
/** 支付单状态展示名(在线支付单与线下凭证单同字段不同语义,按 pay_type 取名) */
|
||||
export function getPayStatusName(payment: { status: PayStatus; pay_type?: PayType }): string {
|
||||
return payment.pay_type === 2 ? ONLINE_PAY_STATUS_NAMES[payment.status] : PAY_STATUS_NAMES[payment.status]
|
||||
}
|
||||
|
||||
/** 支付配置(付款页展示,图片为解析后的预览地址,可能为空串) */
|
||||
export interface PaymentConfig {
|
||||
wechat_qrcode: string
|
||||
@@ -35,17 +53,23 @@ export interface Payment {
|
||||
user_id: number
|
||||
/** 合并付款总金额 */
|
||||
amount: string
|
||||
/** 支付类型:1 线下凭证 / 2 在线支付(旺铺网关) */
|
||||
pay_type?: PayType
|
||||
pay_method: PayMethod
|
||||
/** 凭证图片 ID 数组(模型 casts 为 array) */
|
||||
/** 凭证图片 ID 数组(模型 casts 为 array,在线支付单为空) */
|
||||
voucher_ids: number[]
|
||||
status: PayStatus
|
||||
/** 提交备注 */
|
||||
remark: string
|
||||
/** 审核时间 */
|
||||
/** 审核时间(线下凭证) */
|
||||
audited_at: string | null
|
||||
auditor_id: number | null
|
||||
/** 审核备注(拒绝原因) */
|
||||
/** 审核备注(拒绝原因,线下凭证) */
|
||||
audit_remark: string | null
|
||||
/** 在线支付成功时间(在线支付单非空) */
|
||||
paid_at?: string | null
|
||||
/** 网关交易号(在线支付单非空) */
|
||||
trade_no?: string | null
|
||||
created_at: string
|
||||
/** 列表返回:合并账单数 */
|
||||
bills_count?: number
|
||||
@@ -98,3 +122,46 @@ export function createPaymentApi(data: {
|
||||
export function getPaymentDetailApi(id: number) {
|
||||
return get<PaymentDetail>(`/mini/payment/${id}`)
|
||||
}
|
||||
|
||||
/** 在线支付下单返回(pay_params 为旺铺网关透传的 wx.requestPayment 调起参数,以网关实际返回为准) */
|
||||
export interface OnlinePaymentCreateResult {
|
||||
id: number
|
||||
/** 支付单号(ZF 前缀,= 上送网关的商户订单号 mer_order_id),查询/对账用 */
|
||||
payment_no: string
|
||||
/** 应付金额(= 所选账单总额合计,元) */
|
||||
amount: string
|
||||
pay_params: {
|
||||
timeStamp?: string
|
||||
nonceStr?: string
|
||||
package?: string
|
||||
signType?: string
|
||||
paySign?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线支付结果查询返回 */
|
||||
export interface OnlinePaymentQueryResult {
|
||||
payment_no: string
|
||||
/** 0 待支付 / 1 支付成功(账单已置已支付)/ 2 支付失败(账单已释放) */
|
||||
status: OnlinePayStatus
|
||||
status_name: string
|
||||
paid_at: string | null
|
||||
trade_no: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起在线支付(合并账单下单):POST /mini/payment/online
|
||||
* code 为 wx.login() 返回的登录凭证(后端换付款人 openid)
|
||||
*/
|
||||
export function createOnlinePaymentApi(data: { bill_ids: number[]; code: string; remark?: string }) {
|
||||
return post<OnlinePaymentCreateResult>('/mini/payment/online', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询在线支付结果(网关后台通知延迟/丢失时的兜底):GET /mini/payment/online/{payment_no}/query
|
||||
* 网关返回已支付则立即结账(与后台通知同一幂等逻辑)
|
||||
*/
|
||||
export function queryOnlinePaymentApi(paymentNo: string) {
|
||||
return get<OnlinePaymentQueryResult>(`/mini/payment/online/${paymentNo}/query`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { get } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
import type { CartSummary } from '@/types/cart'
|
||||
import type { Category, Product } from '@/types/product'
|
||||
|
||||
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
|
||||
@@ -17,9 +18,15 @@ export interface ProductListParams {
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/** 商品列表(当前门店等级实际价):GET /mini/product/list */
|
||||
/** 商品列表响应(分页 + 购物车悬浮球汇总) */
|
||||
export interface ProductListData extends PaginatedData<Product> {
|
||||
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||
cart?: CartSummary
|
||||
}
|
||||
|
||||
/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */
|
||||
export function getProductListApi(params: ProductListParams = {}) {
|
||||
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
|
||||
return get<ProductListData>('/mini/product/list', { data: params })
|
||||
}
|
||||
|
||||
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
|
||||
|
||||
@@ -5,13 +5,26 @@ import {
|
||||
clearCartApi,
|
||||
deleteCartItemApi,
|
||||
getCartApi,
|
||||
getCartSummaryApi,
|
||||
updateCartItemApi,
|
||||
} from '@/services/cart'
|
||||
import type { CartItem } from '@/types/cart'
|
||||
import type { CartMutationResult } from '@/services/cart'
|
||||
import { getToken } from '@/utils/request'
|
||||
import type { CartItem, CartSummary } from '@/types/cart'
|
||||
|
||||
/** 存储 key */
|
||||
const STORAGE_KEY = 'cart_data'
|
||||
|
||||
/** 汇总请求序号(并发时仅采用最后一次响应) */
|
||||
let summarySeq = 0
|
||||
/** 汇总防抖校准定时器(列表加减停止 800ms 后整体拉取一次,以服务端为准) */
|
||||
let summaryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 数值 → 2 位小数字符串(与服务端金额/数量口径一致) */
|
||||
function to2(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
|
||||
interface StoredCart {
|
||||
items: CartItem[]
|
||||
@@ -46,8 +59,8 @@ interface CartState {
|
||||
loading: boolean
|
||||
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
|
||||
fetchCart: () => Promise<void>
|
||||
/** 加购 */
|
||||
addItem: (productId: number, quantity: number) => Promise<void>
|
||||
/** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity) */
|
||||
addItem: (productId: number, quantity: number) => Promise<CartMutationResult>
|
||||
/** 修改数量 */
|
||||
updateQuantity: (id: number, quantity: number) => Promise<void>
|
||||
/** 删除单项 */
|
||||
@@ -56,6 +69,12 @@ interface CartState {
|
||||
clearCart: () => Promise<void>
|
||||
/** 下单成功后本地清空(不请求接口) */
|
||||
clearLocal: () => void
|
||||
/** 用接口附带的汇总块(首页/商品列表响应的 cart 字段)直接更新悬浮球 */
|
||||
setSummary: (summary: CartSummary) => void
|
||||
/** 拉取轻量汇总(需登录;未登录跳过,避免 401 跳转) */
|
||||
fetchSummary: () => Promise<void>
|
||||
/** 列表行内加减后本地增减悬浮球(乐观展示),并防抖调 fetchSummary 校准 */
|
||||
applyDelta: (delta: { quantity: number; amount: number; count?: number }) => void
|
||||
}
|
||||
|
||||
/** 空的购物车快照 */
|
||||
@@ -80,6 +99,18 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入悬浮球汇总并持久化(列表项快照保持不变) */
|
||||
const applySummary = (summary: CartSummary) => {
|
||||
const next = {
|
||||
items: get().items,
|
||||
totalCount: summary.total_count,
|
||||
totalQuantity: summary.total_quantity,
|
||||
totalAmount: summary.total_amount,
|
||||
}
|
||||
set(next)
|
||||
persist(next)
|
||||
}
|
||||
|
||||
return {
|
||||
...EMPTY_SNAPSHOT,
|
||||
items: cached?.items ?? [],
|
||||
@@ -111,8 +142,9 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
|
||||
/** 加购:服务端校验上架与等级价,成功后重新同步 */
|
||||
addItem: async (productId, quantity) => {
|
||||
await addCartApi({ product_id: productId, quantity })
|
||||
const res = await addCartApi({ product_id: productId, quantity })
|
||||
await get().fetchCart()
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** 修改数量 */
|
||||
@@ -139,6 +171,36 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
set(EMPTY_SNAPSHOT)
|
||||
persist(EMPTY_SNAPSHOT)
|
||||
},
|
||||
|
||||
/** 写入接口附带的汇总块(首页/商品列表) */
|
||||
setSummary: (summary) => {
|
||||
applySummary(summary)
|
||||
},
|
||||
|
||||
/** 拉取轻量汇总(并发时仅采用最后一次响应) */
|
||||
fetchSummary: async () => {
|
||||
// 未登录无汇总(接口固定 401,会触发清理登录态),直接跳过
|
||||
if (!getToken()) return
|
||||
const seq = ++summarySeq
|
||||
const res = await getCartSummaryApi()
|
||||
if (seq !== summarySeq) return // 已有更新的请求,丢弃本次响应
|
||||
applySummary(res.data)
|
||||
},
|
||||
|
||||
/** 列表加减后的本地增减:即时反馈,防抖后以服务端汇总校准 */
|
||||
applyDelta: ({ quantity, amount, count = 0 }) => {
|
||||
const s = get()
|
||||
applySummary({
|
||||
total_count: Math.max(0, s.totalCount + count),
|
||||
total_quantity: to2(Math.max(0, Number(s.totalQuantity) + quantity)),
|
||||
total_amount: to2(Math.max(0, Number(s.totalAmount) + amount)),
|
||||
})
|
||||
if (summaryTimer) clearTimeout(summaryTimer)
|
||||
summaryTimer = setTimeout(() => {
|
||||
summaryTimer = null
|
||||
get().fetchSummary().catch(() => {})
|
||||
}, 800)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -27,3 +27,16 @@ export interface CartData {
|
||||
/** 可购项总金额 */
|
||||
total_amount: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 购物车悬浮球汇总(/mini/home、/mini/product/list 响应附带;
|
||||
* GET /mini/cart/summary 同构。未登录时列表/首页返回零值结构)
|
||||
*/
|
||||
export interface CartSummary {
|
||||
/** 商品种数(全部行数,含已下架项) */
|
||||
total_count: number
|
||||
/** 总数量(仅可购项,2 位小数字符串) */
|
||||
total_quantity: string
|
||||
/** 总金额(仅可购项,元,2 位小数字符串) */
|
||||
total_amount: string
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ export interface Product {
|
||||
shelf_life?: number | null
|
||||
stock?: number | null
|
||||
status?: number
|
||||
/** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */
|
||||
cart_id?: number
|
||||
/** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00") */
|
||||
cart_quantity?: string
|
||||
}
|
||||
|
||||
/** 商品行购物车字段回写(行内加减购确认后更新列表项) */
|
||||
export interface ProductCartPatch {
|
||||
cart_id: number
|
||||
cart_quantity: string
|
||||
}
|
||||
|
||||
/** 商品首图地址 */
|
||||
|
||||
@@ -78,3 +78,14 @@ export function formatRetailPrice(price?: string | number | null, spec?: string
|
||||
if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null
|
||||
return String(Math.round((p / s) * 100) / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量展示:保留两位小数并去掉尾零("2.50" → "2.5","3.00" → "3")
|
||||
* 用于悬浮球徽标、行内加减器等窄空间;非法值按 0 处理
|
||||
*/
|
||||
export function formatQuantity(value?: string | number | null): string {
|
||||
if (value === null || value === undefined || value === '') return '0'
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return String(Math.round(n * 100) / 100)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user