feat: 盒柜接入运费校验并支持一键合成

本次提交同步补齐小程序端对后端新能力的接入,既支持碎片一键合成,也支持盒柜发货前按商品分类动态判断是否必须支付运费。

- 合成页:新增一键合成入口,展示最大可合成次数,并将单次合成与批量合成交互拆分为更清晰的双按钮布局
- 盒柜页:碎片合成区同步支持批量合成,合成成功后同时刷新配方列表与背包数据
- 运费流程:发货前先调用后端运费检查接口,根据“件数不足”或“包含不包邮商品”展示不同确认文案,再决定是否创建运费订单
- API 封装:补充批量合成与运费检查接口,确保前端逻辑与后端规则保持一致
This commit is contained in:
Zuncle 2026-04-21 02:08:24 +08:00
parent eca0561cd9
commit 575ccb2cfa
4 changed files with 257 additions and 82 deletions

View File

@ -154,6 +154,10 @@ export function requestShipping(user_id, ids, address_id) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, method: 'POST', data })
}
export function checkShippingFee(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/shipping-fee/check`, method: 'POST', data: { inventory_ids: ids } })
}
export function createShippingFeeOrder(user_id, ids) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/shipping-fee/preorder`, method: 'POST', data: { inventory_ids: ids } })
}

View File

@ -8,6 +8,10 @@ export function doSynthesis(userId, recipeId) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/do`, method: 'POST', data: { recipe_id: recipeId } })
}
export function doBatchSynthesis(userId, recipeId) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/do-batch`, method: 'POST', data: { recipe_id: recipeId } })
}
export function getSynthesisLogs(userId, page = 1, pageSize = 20) {
return authRequest({ url: `/api/app/users/${userId}/synthesis/logs`, method: 'GET', data: { page, page_size: pageSize } })
}

View File

@ -93,16 +93,30 @@
<!-- 底部进度 + 按钮 -->
<view class="ticket-footer">
<text class="ready-hint">
{{ getReadyCount(recipe) }}/{{ recipe.materials?.length || 0 }} 材料就绪
</text>
<view
class="synth-btn"
:class="recipe.can_synthesize ? 'btn-ready' : 'btn-locked'"
@tap="onSynthesize(recipe)"
>
<text class="btn-text">{{ synthesizing ? '合成中' : (recipe.can_synthesize ? '合成' : '不足') }}</text>
<view v-if="recipe.can_synthesize" class="btn-shine"></view>
<view class="ready-meta">
<text class="ready-hint">
{{ getReadyCount(recipe) }}/{{ recipe.materials?.length || 0 }} 材料就绪
</text>
<text class="batch-hint" v-if="getMaxSynthesizeCount(recipe) > 0">
最多可合成 {{ getMaxSynthesizeCount(recipe) }}
</text>
</view>
<view class="action-group">
<view
class="synth-btn synth-btn-secondary"
:class="recipe.can_synthesize && !batchSynthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onSynthesize(recipe)"
>
<text class="btn-text">{{ synthesizing ? '合成中' : (recipe.can_synthesize ? '单次合成' : '不足') }}</text>
</view>
<view
class="synth-btn synth-btn-primary"
:class="getMaxSynthesizeCount(recipe) > 0 && !synthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onBatchSynthesize(recipe)"
>
<text class="btn-text">{{ batchSynthesizing ? '批量中' : (getMaxSynthesizeCount(recipe) > 0 ? '一键合成' : '不足') }}</text>
<view v-if="getMaxSynthesizeCount(recipe) > 0 && !batchSynthesizing" class="btn-shine"></view>
</view>
</view>
</view>
</view>
@ -115,10 +129,11 @@
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getSynthesisRecipes, doSynthesis } from '../../api/synthesis.js'
import { getSynthesisRecipes, doSynthesis, doBatchSynthesis } from '../../api/synthesis.js'
const loading = ref(true)
const synthesizing = ref(false)
const batchSynthesizing = ref(false)
const isRefreshing = ref(false)
const recipes = ref([])
@ -142,6 +157,21 @@ function getOverallProgress(recipe) {
return Math.round((getReadyCount(recipe) / recipe.materials.length) * 100)
}
function getMaxSynthesizeCount(recipe) {
return Number(recipe?.max_synthesize_count || 0)
}
function confirmSynthesis({ title, content }) {
return new Promise((resolve, reject) => {
uni.showModal({
title,
content,
success: (res) => res.confirm ? resolve() : reject(new Error('cancel')),
fail: reject
})
})
}
async function loadRecipes() {
loading.value = true
const userId = uni.getStorageSync('user_id')
@ -166,15 +196,11 @@ async function onRefresh() {
}
async function onSynthesize(recipe) {
if (synthesizing.value || !recipe.can_synthesize) return
if (synthesizing.value || batchSynthesizing.value || !recipe.can_synthesize) return
try {
await new Promise((resolve, reject) => {
uni.showModal({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`,
success: (res) => res.confirm ? resolve() : reject('cancel'),
fail: reject
})
await confirmSynthesis({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`
})
} catch {
return
@ -193,6 +219,33 @@ async function onSynthesize(recipe) {
}
}
async function onBatchSynthesize(recipe) {
const maxCount = getMaxSynthesizeCount(recipe)
if (batchSynthesizing.value || synthesizing.value || maxCount <= 0) return
try {
await confirmSynthesis({
title: '确认一键合成',
content: `将消耗当前全部可用碎片,预计合成 ${maxCount} 次「${recipe.target_product?.name || '目标商品'}」,是否继续?`
})
} catch {
return
}
batchSynthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
const res = await doBatchSynthesis(userId, recipe.id)
const count = Number(res?.synthesized_count || maxCount)
uni.showToast({ title: `一键合成成功,共合成 ${count}`, icon: 'none' })
await loadRecipes()
} catch (e) {
uni.showToast({ title: e?.message || '一键合成失败', icon: 'none' })
} finally {
batchSynthesizing.value = false
}
}
onLoad(() => {
loadRecipes()
})
@ -535,27 +588,47 @@ defineExpose({ onShow })
/* 底部行:进度提示 + 按钮 */
.ticket-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 14rpx;
margin-top: 4rpx;
}
.ready-meta {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ready-hint {
font-size: 20rpx;
color: $text-tertiary;
}
.batch-hint {
font-size: 20rpx;
color: $brand-primary;
font-weight: 600;
}
.action-group {
display: flex;
align-items: center;
gap: 12rpx;
}
/* 合成按钮 - 小胶囊 */
.synth-btn {
height: 56rpx;
padding: 0 28rpx;
border-radius: 28rpx;
flex: 1;
min-width: 0;
height: 52rpx;
padding: 0 16rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&.btn-ready {
@ -563,7 +636,7 @@ defineExpose({ onShow })
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.94);
transform: scale(0.96);
opacity: 0.9;
}
}
@ -573,15 +646,25 @@ defineExpose({ onShow })
}
}
.synth-btn-primary {
min-width: 0;
}
.synth-btn-secondary {
&.btn-ready {
background: linear-gradient(135deg, rgba($brand-primary, 0.14), rgba($brand-primary, 0.08));
box-shadow: none;
border: 1.5rpx solid rgba($brand-primary, 0.25);
}
}
.btn-text {
font-size: 24rpx;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
letter-spacing: 0;
position: relative;
z-index: 2;
.btn-ready & { color: #fff; }
.btn-locked & { color: $text-tertiary; }
white-space: nowrap;
}
.btn-shine {

View File

@ -213,16 +213,30 @@
</view>
</view>
<view class="ticket-footer">
<text class="ready-hint">
{{ getSynthReadyCount(recipe) }}/{{ recipe.materials?.length || 0 }} 材料就绪
</text>
<view
class="synth-btn"
:class="recipe.can_synthesize ? 'btn-ready' : 'btn-locked'"
@tap="onSynthesize(recipe)"
>
<text class="btn-text">{{ synthesizing ? '合成中' : (recipe.can_synthesize ? '合成' : '不足') }}</text>
<view v-if="recipe.can_synthesize" class="btn-shine"></view>
<view class="ready-meta">
<text class="ready-hint">
{{ getSynthReadyCount(recipe) }}/{{ recipe.materials?.length || 0 }} 材料就绪
</text>
<text class="batch-hint" v-if="getSynthMaxCount(recipe) > 0">
最多可合成 {{ getSynthMaxCount(recipe) }}
</text>
</view>
<view class="action-group">
<view
class="synth-btn synth-btn-secondary"
:class="recipe.can_synthesize && !batchSynthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onSynthesize(recipe)"
>
<text class="btn-text">{{ synthesizing ? '合成中' : (recipe.can_synthesize ? '单次合成' : '不足') }}</text>
</view>
<view
class="synth-btn synth-btn-primary"
:class="getSynthMaxCount(recipe) > 0 && !synthesizing ? 'btn-ready' : 'btn-locked'"
@tap="onBatchSynthesize(recipe)"
>
<text class="btn-text">{{ batchSynthesizing ? '批量中' : (getSynthMaxCount(recipe) > 0 ? '一键合成' : '不足') }}</text>
<view v-if="getSynthMaxCount(recipe) > 0 && !batchSynthesizing" class="btn-shine"></view>
</view>
</view>
</view>
</view>
@ -294,8 +308,8 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow, onReachBottom, onShareAppMessage, onPullDownRefresh } from '@dcloudio/uni-app'
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare, createShippingFeeOrder } from '@/api/appUser'
import { getSynthesisRecipes, doSynthesis } from '@/api/synthesis.js'
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare, checkShippingFee, createShippingFeeOrder } from '@/api/appUser'
import { getSynthesisRecipes, doSynthesis, doBatchSynthesis } from '@/api/synthesis.js'
import { vibrateShort } from '@/utils/vibrate.js'
import { checkPhoneBoundSync } from '@/utils/checkPhone.js'
import { executePaymentFlow } from '@/utils/payment.js'
@ -326,6 +340,7 @@ const pendingShipIds = ref([])
const recipes = ref([])
const synthLoading = ref(false)
const synthesizing = ref(false)
const batchSynthesizing = ref(false)
const totalCount = computed(() => {
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
@ -763,6 +778,21 @@ function getSynthReadyCount(recipe) {
return recipe.materials.filter(m => m.owned_count >= m.required_count).length
}
function getSynthMaxCount(recipe) {
return Number(recipe?.max_synthesize_count || 0)
}
function confirmSynthesisAction({ title, content }) {
return new Promise((resolve, reject) => {
uni.showModal({
title,
content,
success: (res) => res.confirm ? resolve() : reject(new Error('cancel')),
fail: reject
})
})
}
async function loadRecipes(uid) {
synthLoading.value = true
const userId = uid || uni.getStorageSync('user_id')
@ -778,23 +808,21 @@ async function loadRecipes(uid) {
}
async function onSynthesize(recipe) {
if (synthesizing.value || !recipe.can_synthesize) return
if (synthesizing.value || batchSynthesizing.value || !recipe.can_synthesize) return
try {
await new Promise((resolve, reject) => {
uni.showModal({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`,
success: (res) => res.confirm ? resolve() : reject('cancel'),
fail: reject
})
await confirmSynthesisAction({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`
})
} catch { return }
} catch {
return
}
synthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
await doSynthesis(userId, recipe.id)
uni.showToast({ title: '合成成功!', icon: 'success' })
await loadRecipes(userId)
await Promise.all([loadRecipes(userId), loadInventory(userId)])
} catch (e) {
uni.showToast({ title: e?.message || '合成失败', icon: 'none' })
} finally {
@ -802,6 +830,32 @@ async function onSynthesize(recipe) {
}
}
async function onBatchSynthesize(recipe) {
const maxCount = getSynthMaxCount(recipe)
if (batchSynthesizing.value || synthesizing.value || maxCount <= 0) return
try {
await confirmSynthesisAction({
title: '确认一键合成',
content: `将消耗当前全部可用碎片,预计合成 ${maxCount} 次「${recipe.target_product?.name || '目标商品'}」,是否继续?`
})
} catch {
return
}
batchSynthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
const res = await doBatchSynthesis(userId, recipe.id)
const count = Number(res?.synthesized_count || maxCount)
uni.showToast({ title: `一键合成成功,共合成 ${count}`, icon: 'none' })
await Promise.all([loadRecipes(userId), loadInventory(userId)])
} catch (e) {
uni.showToast({ title: e?.message || '一键合成失败', icon: 'none' })
} finally {
batchSynthesizing.value = false
}
}
async function onRedeem() {
vibrateShort()
const user_id = uni.getStorageSync('user_id')
@ -907,13 +961,26 @@ async function confirmShipWithAddress() {
showAddressPicker.value = false
const FREIGHT_THRESHOLD = 5
const FREIGHT_FEE = 10
if (allIds.length < FREIGHT_THRESHOLD) {
let shippingCheck
try {
shippingCheck = await checkShippingFee(user_id, allIds)
} catch (e) {
uni.showToast({ title: e?.message || '运费校验失败', icon: 'none' })
return
}
if (shippingCheck?.need_fee) {
const fee = Number(shippingCheck?.fee_cents || 0) / 100
const reason = shippingCheck?.reason
const content = reason === 'contains_non_free_shipping_item'
? `所选商品包含不包邮商品,需支付 ¥${fee.toFixed(2)} 运费,确认继续?`
: `${allIds.length} 件商品,不满 ${FREIGHT_THRESHOLD} 件需支付 ¥${fee.toFixed(2)} 运费,确认继续?`
const confirmed = await new Promise((resolve) => {
uni.showModal({
title: '需支付运费',
content: `${allIds.length} 件商品,不满 ${FREIGHT_THRESHOLD} 件需支付 ¥${FREIGHT_FEE}.00 运费,确认继续?`,
content,
confirmText: '去支付',
cancelText: '取消',
success: (res) => resolve(res.confirm)
@ -934,25 +1001,8 @@ async function confirmShipWithAddress() {
return
}
uni.hideLoading()
uni.showLoading({ title: '提交中...' })
try {
await requestShipping(user_id, allIds, addressId)
uni.showToast({ title: '申请成功', icon: 'success' })
pendingShipIds.value = []
aggregatedList.value = []
page.value = 1
hasMore.value = true
loadInventory(user_id)
} catch (e) {
uni.showToast({ title: e.message || '申请失败', icon: 'none' })
} finally {
uni.hideLoading()
}
return
}
// 5
uni.showLoading({ title: '提交中...' })
try {
await requestShipping(user_id, allIds, addressId)
@ -1977,33 +2027,54 @@ function onCopyShareLink() {
.ticket-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 14rpx;
margin-top: 4rpx;
}
.ready-meta {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ready-hint {
font-size: 20rpx;
color: $text-tertiary;
}
.batch-hint {
font-size: 20rpx;
color: $brand-primary;
font-weight: 600;
}
.action-group {
display: flex;
align-items: center;
gap: 12rpx;
}
.synth-btn {
height: 56rpx;
padding: 0 28rpx;
border-radius: 28rpx;
flex: 1;
min-width: 0;
height: 52rpx;
padding: 0 16rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&.btn-ready {
background: $gradient-brand;
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.94);
transform: scale(0.96);
opacity: 0.9;
}
}
@ -2013,12 +2084,25 @@ function onCopyShareLink() {
}
}
.synth-btn-primary {
min-width: 0;
}
.synth-btn-secondary {
&.btn-ready {
background: linear-gradient(135deg, rgba($brand-primary, 0.14), rgba($brand-primary, 0.08));
box-shadow: none;
border: 1.5rpx solid rgba($brand-primary, 0.25);
}
}
.btn-text {
font-size: 24rpx;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
letter-spacing: 0;
position: relative;
z-index: 2;
white-space: nowrap;
.btn-ready & { color: #fff; }
.btn-locked & { color: $text-tertiary; }