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

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

700 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 装饰光球 -->
<view class="orb orb-1"></view>
<view class="orb orb-2"></view>
<view class="header-area">
<view class="page-title">碎片合成</view>
<view class="page-subtitle">Fragment Synthesis</view>
</view>
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="recipes.length === 0" class="empty-state">
<text class="empty-icon">🧩</text>
<text class="empty-text">暂无可用的合成配方</text>
<text class="empty-hint">敬请期待更多合成方案</text>
</view>
<!-- 配方卡片列表 -->
<view v-else class="recipe-list">
<view
v-for="(recipe, index) in recipes"
:key="recipe.id"
class="recipe-ticket"
:class="{ 'ticket-ready': recipe.can_synthesize }"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 左侧目标商品主视觉 -->
<view class="ticket-left">
<view class="product-img-wrap">
<image
v-if="recipe.target_product"
:src="getFirstImage(recipe.target_product.images_json)"
mode="aspectFill"
class="product-img"
/>
<view v-else class="product-img-placeholder">
<text>🎁</text>
</view>
</view>
<!-- 可合成光晕 -->
<view v-if="recipe.can_synthesize" class="ready-glow"></view>
<view class="product-label">
<text class="label-text" :class="recipe.can_synthesize ? 'label-ready' : 'label-lack'">
{{ recipe.can_synthesize ? '可合成' : '待收集' }}
</text>
</view>
</view>
<!-- 分割线带缺口 -->
<view class="ticket-divider">
<view class="notch notch-top"></view>
<view class="divider-line"></view>
<view class="notch notch-bottom"></view>
</view>
<!-- 右侧配方信息 -->
<view class="ticket-right">
<view class="ticket-info">
<text class="product-name">{{ recipe.target_product?.name || '目标商品' }}</text>
<text class="recipe-name">{{ recipe.name }}</text>
<text class="recipe-desc" v-if="recipe.description">{{ recipe.description }}</text>
</view>
<!-- 材料清单 -->
<view class="materials-row">
<view
v-for="(mat, idx) in recipe.materials"
:key="idx"
class="mat-chip"
:class="mat.owned_count >= mat.required_count ? 'mat-ok' : 'mat-lack'"
>
<text class="mat-name">{{ mat.name }}</text>
<text class="mat-num">{{ mat.owned_count }}/{{ mat.required_count }}</text>
</view>
</view>
<!-- 底部进度 + 按钮 -->
<view class="ticket-footer">
<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>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
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([])
function getFirstImage(imagesJson) {
if (!imagesJson) return '/static/placeholder.png'
try {
const imgs = JSON.parse(imagesJson)
return imgs && imgs.length > 0 ? imgs[0] : '/static/placeholder.png'
} catch {
return imagesJson
}
}
function getReadyCount(recipe) {
if (!recipe.materials) return 0
return recipe.materials.filter(m => m.owned_count >= m.required_count).length
}
function getOverallProgress(recipe) {
if (!recipe.materials || recipe.materials.length === 0) return 0
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')
if (!userId) {
loading.value = false
return
}
try {
const res = await getSynthesisRecipes(userId)
recipes.value = res?.list || []
} catch (e) {
console.error('loadRecipes error', e)
} finally {
loading.value = false
}
}
async function onRefresh() {
isRefreshing.value = true
await loadRecipes()
isRefreshing.value = false
}
async function onSynthesize(recipe) {
if (synthesizing.value || batchSynthesizing.value || !recipe.can_synthesize) return
try {
await confirmSynthesis({
title: '确认合成',
content: `确定要合成「${recipe.target_product?.name || '目标商品'}」吗?合成后碎片将被消耗。`
})
} catch {
return
}
synthesizing.value = true
const userId = uni.getStorageSync('user_id')
try {
await doSynthesis(userId, recipe.id)
uni.showToast({ title: '合成成功!', icon: 'success' })
await loadRecipes()
} catch (e) {
uni.showToast({ title: e?.message || '合成失败', icon: 'none' })
} finally {
synthesizing.value = false
}
}
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()
})
const onShow = () => {
if (!loading.value) loadRecipes()
}
defineExpose({ onShow })
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 装饰光球 */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80rpx);
opacity: 0.5;
pointer-events: none;
}
.orb-1 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.25), transparent 70%);
top: -80rpx;
right: -80rpx;
animation: float 10s ease-in-out infinite;
}
.orb-2 {
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15), transparent 70%);
bottom: 200rpx;
left: -100rpx;
animation: float 14s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(20rpx, 30rpx); }
}
/* Header */
.header-area {
padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative;
z-index: 1;
}
.page-title {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.page-subtitle {
font-size: 24rpx;
color: $text-tertiary;
text-transform: uppercase;
letter-spacing: 2rpx;
font-weight: 600;
}
/* 滚动区 */
.content-scroll {
height: calc(100vh - 220rpx);
position: relative;
z-index: 1;
}
/* 加载 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $spacing-lg;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 10rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 配方列表 */
.recipe-list {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 0 $spacing-lg $spacing-xl;
}
/* 票券式卡片 */
.recipe-ticket {
display: flex;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: $shadow-card;
animation: fadeInUp 0.5s ease-out backwards;
position: relative;
&.ticket-ready {
box-shadow:
$shadow-card,
0 0 0 1.5rpx rgba($brand-primary, 0.25);
}
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(24rpx); }
to { opacity: 1; transform: translateY(0); }
}
/* 左侧商品区 */
.ticket-left {
width: 200rpx;
flex-shrink: 0;
background: linear-gradient(145deg, #FFF5EC, #FFF0E0);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28rpx 16rpx;
position: relative;
overflow: hidden;
}
.ticket-ready .ticket-left {
background: linear-gradient(145deg, #FFF5EC, #FFE8C8);
}
/* 可合成光晕 */
.ready-glow {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 40%, rgba($brand-primary, 0.12), transparent 70%);
pointer-events: none;
}
.product-img-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.08);
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.product-img {
width: 100%;
height: 100%;
}
.product-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
}
.product-label {
padding: 4rpx 16rpx;
border-radius: 100rpx;
background: rgba(255, 255, 255, 0.8);
}
.label-text {
font-size: 20rpx;
font-weight: 700;
&.label-ready {
color: $brand-primary;
}
&.label-lack {
color: $text-tertiary;
}
}
/* 分割线(票券缺口效果) */
.ticket-divider {
width: 28rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.notch {
width: 28rpx;
height: 28rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 0;
z-index: 2;
&.notch-top { top: -14rpx; }
&.notch-bottom { bottom: -14rpx; }
}
.divider-line {
width: 0;
height: 75%;
border-left: 2rpx dashed rgba(0, 0, 0, 0.1);
}
/* 右侧信息区 */
.ticket-right {
flex: 1;
padding: 24rpx 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
overflow: hidden;
min-width: 0;
}
.ticket-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.product-name {
font-size: 30rpx;
font-weight: 800;
color: $text-main;
letter-spacing: 0.5rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-name {
font-size: 22rpx;
color: $text-sub;
font-weight: 500;
}
.recipe-desc {
font-size: 20rpx;
color: $text-tertiary;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 材料芯片行 */
.materials-row {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.mat-chip {
display: flex;
align-items: center;
gap: 6rpx;
padding: 5rpx 12rpx;
border-radius: 100rpx;
background: rgba(0, 0, 0, 0.04);
border: 1.5rpx solid rgba(0, 0, 0, 0.06);
&.mat-ok {
background: rgba($color-success, 0.08);
border-color: rgba($color-success, 0.2);
}
&.mat-lack {
background: rgba($color-error, 0.06);
border-color: rgba($color-error, 0.15);
}
}
.mat-name {
font-size: 20rpx;
color: $text-sub;
max-width: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-num {
font-size: 20rpx;
font-weight: 700;
color: $text-tertiary;
.mat-ok & { color: $color-success; }
.mat-lack & { color: $color-error; }
}
/* 底部行:进度提示 + 按钮 */
.ticket-footer {
display: flex;
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 {
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;
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.96);
opacity: 0.9;
}
}
&.btn-locked {
background: rgba(0, 0, 0, 0.05);
}
}
.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: 22rpx;
font-weight: 700;
letter-spacing: 0;
position: relative;
z-index: 2;
white-space: nowrap;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: skewX(-20deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 200%; }
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>