Compare commits
6 Commits
16076f2eb8
...
fd252efae1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd252efae1 | ||
| bdd329eb15 | |||
| 3e20dd845a | |||
| bcbe7a9b29 | |||
| be915a1507 | |||
| 499ac1514e |
@ -152,6 +152,10 @@ export function requestShipping(user_id, ids) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/request-shipping`, 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 } })
|
||||
}
|
||||
|
||||
export function cancelShipping(user_id, batch_no) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/inventory/cancel-shipping`, method: 'POST', data: { batch_no } })
|
||||
}
|
||||
@ -188,10 +192,6 @@ export function redeemCouponByPoints(user_id, coupon_id) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-coupon`, method: 'POST', data: { coupon_id } })
|
||||
}
|
||||
|
||||
export function transferCoupon(user_id, user_coupon_id, receiver_id) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/coupons/${user_coupon_id}/transfer`, method: 'POST', data: { receiver_id } })
|
||||
}
|
||||
|
||||
export function redeemCoupon(user_id, code) {
|
||||
return authRequest({ url: `/api/app/users/${user_id}/coupons/redeem`, method: 'POST', data: { code } })
|
||||
}
|
||||
|
||||
13
api/synthesis.js
Normal file
13
api/synthesis.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { authRequest } from '../utils/request'
|
||||
|
||||
export function getSynthesisRecipes(userId) {
|
||||
return authRequest({ url: `/api/app/users/${userId}/synthesis/recipes`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function doSynthesis(userId, recipeId) {
|
||||
return authRequest({ url: `/api/app/users/${userId}/synthesis/do`, 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 } })
|
||||
}
|
||||
@ -427,7 +427,7 @@ function handleClose() {
|
||||
.title {
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
|
||||
background: $gradient-brand;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@ -451,7 +451,7 @@ function handleClose() {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
color: #667EEA;
|
||||
color: $brand-primary;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
@ -500,7 +500,7 @@ function handleClose() {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6rpx;
|
||||
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%);
|
||||
background: $gradient-brand;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
@ -607,7 +607,7 @@ function handleClose() {
|
||||
}
|
||||
|
||||
.btn-buy {
|
||||
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
|
||||
background: $gradient-brand;
|
||||
color: #FFF;
|
||||
font-size: 26rpx;
|
||||
padding: 0 28rpx;
|
||||
@ -657,7 +657,7 @@ function handleClose() {
|
||||
line-height: 44rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
color: #667EEA;
|
||||
color: $brand-primary;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
@ -673,8 +673,8 @@ function handleClose() {
|
||||
color: #9CA3AF;
|
||||
|
||||
&:active {
|
||||
color: #667EEA;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: $brand-primary;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -699,9 +699,9 @@ function handleClose() {
|
||||
|
||||
// 选中套餐状态
|
||||
.package-item.selected {
|
||||
border-color: #667EEA;
|
||||
background: linear-gradient(145deg, #F8F9FF 0%, #EEF0FF 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(102, 126, 234, 0.2);
|
||||
border-color: $brand-primary;
|
||||
background: linear-gradient(145deg, #FFF8F4 0%, #FFF0E6 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba($brand-primary, 0.2);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
@ -826,7 +826,7 @@ function handleClose() {
|
||||
}
|
||||
|
||||
.btn-checkout {
|
||||
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%);
|
||||
background: $gradient-brand;
|
||||
color: #FFF;
|
||||
font-size: 32rpx;
|
||||
padding: 0 48rpx;
|
||||
|
||||
@ -41,7 +41,9 @@
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
|
||||
@ -1502,6 +1502,7 @@ async function fetchCoupons() {
|
||||
if (Array.isArray(res)) list = res
|
||||
else if (res && Array.isArray(res.list)) list = res.list
|
||||
else if (res && Array.isArray(res.data)) list = res.data
|
||||
list = list.filter(i => i.sub_status !== 'expired')
|
||||
coupons.value = list.map((i, idx) => {
|
||||
const cents = (i.remaining !== undefined && i.remaining !== null) ? Number(i.remaining) : Number(i.amount ?? i.value ?? 0)
|
||||
const yuan = isNaN(cents) ? 0 : (cents / 100)
|
||||
@ -2581,8 +2582,8 @@ onLoad((opts) => {
|
||||
color: #fff;
|
||||
|
||||
&.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
|
||||
background: $gradient-brand;
|
||||
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.4);
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
|
||||
@ -307,6 +307,7 @@ async function fetchCoupons() {
|
||||
try {
|
||||
const res = await getUserCoupons(user_id, 0, 1, 100)
|
||||
let list = Array.isArray(res) ? res : (res?.list || res?.data || [])
|
||||
list = list.filter(i => i.sub_status !== 'expired')
|
||||
coupons.value = list.map((i, idx) => {
|
||||
const amountCents = i.remaining ?? i.amount ?? i.value ?? 0
|
||||
const amt = isNaN(amountCents) ? 0 : (Number(amountCents) / 100)
|
||||
|
||||
@ -624,9 +624,7 @@ async function fetchCoupons() {
|
||||
// 获取未使用(status=1)的优惠券
|
||||
const res = await getUserCoupons(userId, 1, 1, 100)
|
||||
if (res && Array.isArray(res.list)) {
|
||||
// 简单过滤:只显示未过期的(虽然接口可能已过滤)
|
||||
// TODO: 如果需要根据活动ID过滤适用券,需后端支持或在此处根据规则过滤
|
||||
paymentCoupons.value = res.list
|
||||
paymentCoupons.value = res.list.filter(c => c.sub_status !== 'expired')
|
||||
} else {
|
||||
paymentCoupons.value = []
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ export default {
|
||||
const targetCode = code || this.gameCode
|
||||
if (targetCode !== 'minesweeper_free' && this.ticketCount <= 0) return
|
||||
if (this.entering) return
|
||||
|
||||
|
||||
this.entering = true
|
||||
try {
|
||||
const res = await authRequest({
|
||||
@ -120,13 +120,10 @@ export default {
|
||||
game_code: targetCode
|
||||
}
|
||||
})
|
||||
|
||||
const gameToken = encodeURIComponent(res.game_token)
|
||||
const nakamaServer = encodeURIComponent(res.nakama_server)
|
||||
const nakamaKey = encodeURIComponent(res.nakama_key)
|
||||
|
||||
|
||||
const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws'
|
||||
uni.navigateTo({
|
||||
url: `/pages-game/game/minesweeper/play?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||
url: `/pages-game/game/minesweeper/play?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}`
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
@ -139,7 +136,6 @@ export default {
|
||||
}
|
||||
},
|
||||
async goRoomList() {
|
||||
// 获取配置以拿到 nakama 参数,虽然 room-list 也会尝试连接,但通过 URL 传参更稳妥
|
||||
try {
|
||||
const res = await authRequest({
|
||||
url: '/api/app/games/enter',
|
||||
@ -148,13 +144,10 @@ export default {
|
||||
game_code: this.gameCode
|
||||
}
|
||||
})
|
||||
|
||||
const gameToken = encodeURIComponent(res.game_token)
|
||||
const nakamaServer = encodeURIComponent(res.nakama_server)
|
||||
const nakamaKey = encodeURIComponent(res.nakama_key)
|
||||
|
||||
|
||||
const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws'
|
||||
uni.navigateTo({
|
||||
url: `/pages-game/game/minesweeper/room-list?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
|
||||
url: `/pages-game/game/minesweeper/room-list?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}`
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '无法获取对战列表', icon: 'none' })
|
||||
|
||||
@ -12,23 +12,8 @@ const url = ref('')
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.url) {
|
||||
let targetUrl = decodeURIComponent(options.url)
|
||||
|
||||
// Append auth info if not present
|
||||
const token = uni.getStorageSync('token')
|
||||
const uid = uni.getStorageSync('user_id')
|
||||
|
||||
const hasQuery = targetUrl.includes('?')
|
||||
const separator = hasQuery ? '&' : '?'
|
||||
|
||||
// Append standard auth params for the game to consume
|
||||
if (token) targetUrl += `${separator}token=${encodeURIComponent(token)}`
|
||||
if (uid) targetUrl += `&uid=${encodeURIComponent(uid)}`
|
||||
// Append ticket if provided
|
||||
if (options.ticket) targetUrl += `&ticket=${encodeURIComponent(options.ticket)}`
|
||||
|
||||
console.log('Opening Game WebView:', targetUrl)
|
||||
url.value = targetUrl
|
||||
url.value = decodeURIComponent(options.url)
|
||||
console.log('Opening Game WebView:', url.value)
|
||||
} else {
|
||||
uni.showToast({ title: '游戏地址无效', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { request } from '@/utils/request'
|
||||
import { listAddresses } from '@/api/appUser'
|
||||
|
||||
@ -95,15 +95,39 @@ const form = reactive({
|
||||
onLoad((options) => {
|
||||
if (options.token) {
|
||||
token.value = options.token
|
||||
// 如果已登录,加载用户的地址列表
|
||||
if (isLoggedIn.value) {
|
||||
loadAddressList()
|
||||
// 检查登录状态,未登录则引导登录
|
||||
if (!isLoggedIn.value) {
|
||||
uni.showModal({
|
||||
title: '需要登录',
|
||||
content: '请先登录后再填写收货地址,以便将奖品转移至您的账户',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const currentPage = `/pages-user/address/submit?token=${token.value}`
|
||||
uni.navigateTo({ url: `/pages/login/index?redirect=${encodeURIComponent(currentPage)}` })
|
||||
} else {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
loadAddressList()
|
||||
} else {
|
||||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||
}
|
||||
})
|
||||
|
||||
// 从登录页返回后,重新检测登录状态
|
||||
onShow(() => {
|
||||
const newLoginState = !!uni.getStorageSync('token')
|
||||
if (newLoginState && !isLoggedIn.value) {
|
||||
isLoggedIn.value = true
|
||||
loadAddressList()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载用户地址列表
|
||||
async function loadAddressList() {
|
||||
if (!isLoggedIn.value) return
|
||||
@ -145,6 +169,10 @@ function onRegionChange(e) {
|
||||
|
||||
async function onSubmit() {
|
||||
if (!token.value) return
|
||||
if (!isLoggedIn.value) {
|
||||
uni.showToast({ title: '请先登录后再提交', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.name || !form.mobile || !form.province || !form.address) {
|
||||
uni.showToast({ title: '请完善收货信息', icon: 'none' })
|
||||
return
|
||||
|
||||
@ -94,7 +94,6 @@
|
||||
|
||||
<!-- 优化后的按钮位置 -->
|
||||
<view class="coupon-action-wrapper" v-if="currentTab === 1">
|
||||
<view class="transfer-link" @click.stop="onTransferCoupon(item)">转赠给好友</view>
|
||||
<view class="use-btn" @click.stop="onUseCoupon(item)">
|
||||
<text class="btn-text">去使用</text>
|
||||
<view class="btn-shine"></view>
|
||||
@ -119,8 +118,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getUserCoupons, transferCoupon } from '../../api/appUser'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getUserCoupons } from '../../api/appUser'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
|
||||
const list = ref([])
|
||||
@ -321,43 +320,6 @@ function onUseCoupon(item) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 转赠优惠券
|
||||
function onTransferCoupon(item) {
|
||||
vibrateShort()
|
||||
uni.showModal({
|
||||
title: '转赠优惠券',
|
||||
content: '请输入接收方用户 ID',
|
||||
editable: true,
|
||||
placeholderText: '请输入用户ID',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
const receiverId = parseInt(res.content)
|
||||
if (isNaN(receiverId)) {
|
||||
uni.showToast({ title: '请输入有效的用户ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '转赠中...' })
|
||||
try {
|
||||
const userId = getUserId()
|
||||
await transferCoupon(userId, item.id, receiverId)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '转赠成功', icon: 'success' })
|
||||
// 刷新列表
|
||||
onRefresh()
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showModal({
|
||||
title: '转赠失败',
|
||||
content: e.message || '操作失败',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
fetchData()
|
||||
})
|
||||
@ -717,17 +679,7 @@ onLoad(() => {
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.transfer-link {
|
||||
font-size: 22rpx;
|
||||
color: $brand-primary;
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
&:active { opacity: 1; }
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.use-btn {
|
||||
|
||||
@ -570,10 +570,10 @@ onLoad(() => {
|
||||
}
|
||||
|
||||
.use-btn {
|
||||
background: linear-gradient(135deg, #4facfe, #00f2fe);
|
||||
background: $gradient-brand;
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 40rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 150, 250, 0.2);
|
||||
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
@ -1001,9 +1001,9 @@ function exportReceipt() {
|
||||
gap: 12rpx;
|
||||
margin-top: $spacing-lg;
|
||||
padding: 20rpx 32rpx;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
|
||||
background: $gradient-brand;
|
||||
border-radius: 40rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.3);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
|
||||
616
pages-user/synthesis/index.vue
Normal file
616
pages-user/synthesis/index.vue
Normal file
@ -0,0 +1,616 @@
|
||||
<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">
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getSynthesisRecipes, doSynthesis } from '../../api/synthesis.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const synthesizing = 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)
|
||||
}
|
||||
|
||||
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 || !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
|
||||
})
|
||||
})
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.ready-hint {
|
||||
font-size: 20rpx;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
/* 合成按钮 - 小胶囊 */
|
||||
.synth-btn {
|
||||
height: 56rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 28rpx;
|
||||
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);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-locked {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.btn-ready & { color: #fff; }
|
||||
.btn-locked & { color: $text-tertiary; }
|
||||
}
|
||||
|
||||
.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>
|
||||
@ -69,6 +69,13 @@
|
||||
<view class="task-meta">
|
||||
<text class="task-name">{{ task.name }}</text>
|
||||
<text class="task-desc">{{ task.description }}</text>
|
||||
<view class="task-time-row" v-if="getTaskTimeRangeText(task) || getTaskCountdownText(task, nowMs)">
|
||||
<text class="task-time-range" v-if="getTaskTimeRangeText(task)">{{ getTaskTimeRangeText(task) }}</text>
|
||||
<text class="task-time-countdown" :class="{ expired: getTaskCountdownText(task, nowMs) === '已截止' }" v-if="getTaskCountdownText(task, nowMs)">
|
||||
{{ getTaskCountdownText(task, nowMs) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="task-rule-tip" v-if="getTaskRuleTip(task)">{{ getTaskRuleTip(task) }}</text>
|
||||
|
||||
<!-- 新增:独立进度展示 (当存在 sub_progress 时显示) -->
|
||||
<view class="sub-progress-list" v-if="taskProgress[task.id]?.subProgress?.length > 0">
|
||||
@ -161,15 +168,18 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
import { parseTimeMs } from '@/utils/format.js'
|
||||
|
||||
const tasks = ref([])
|
||||
const loading = ref(false)
|
||||
const isRefreshing = ref(false)
|
||||
const expandedTasks = reactive({})
|
||||
const claiming = reactive({})
|
||||
const nowMs = ref(Date.now())
|
||||
let countdownTimer = null
|
||||
|
||||
// 用户进度 (汇总 - 用于顶部统计卡片显示)
|
||||
const userProgress = reactive({
|
||||
@ -219,14 +229,133 @@ function getTaskIcon(task) {
|
||||
return '⭐'
|
||||
}
|
||||
|
||||
function parseTaskTimeMs(value) {
|
||||
const ms = parseTimeMs(value)
|
||||
if (!ms || ms <= 0) return null
|
||||
return ms
|
||||
}
|
||||
|
||||
function formatMonthDay(timestampMs) {
|
||||
if (!timestampMs) return ''
|
||||
const date = new Date(timestampMs)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getMonth() + 1}.${date.getDate()}`
|
||||
}
|
||||
|
||||
function getTaskTimeRangeText(task) {
|
||||
const startMs = parseTaskTimeMs(task?.start_time)
|
||||
const endMs = parseTaskTimeMs(task?.end_time)
|
||||
if (startMs && endMs) return `活动时间 ${formatMonthDay(startMs)}-${formatMonthDay(endMs)}`
|
||||
if (startMs) return `活动开始 ${formatMonthDay(startMs)}`
|
||||
if (endMs) return `活动截止 ${formatMonthDay(endMs)}`
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatCountdown(diffMs) {
|
||||
const totalMinutes = Math.ceil(diffMs / (60 * 1000))
|
||||
if (totalMinutes <= 0) return '0分钟'
|
||||
const day = Math.floor(totalMinutes / (24 * 60))
|
||||
const hour = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minute = totalMinutes % 60
|
||||
if (day > 0) {
|
||||
if (hour > 0) return `${day}天${hour}小时`
|
||||
return `${day}天`
|
||||
}
|
||||
if (hour > 0) {
|
||||
if (minute > 0) return `${hour}小时${minute}分钟`
|
||||
return `${hour}小时`
|
||||
}
|
||||
return `${minute}分钟`
|
||||
}
|
||||
|
||||
function getTaskCountdownText(task, currentMs = Date.now()) {
|
||||
const endMs = parseTaskTimeMs(task?.end_time)
|
||||
if (!endMs) return ''
|
||||
const diffMs = endMs - currentMs
|
||||
if (diffMs <= 0) return '已截止'
|
||||
return `距截止 ${formatCountdown(diffMs)}`
|
||||
}
|
||||
|
||||
function hasDailyWindow(task) {
|
||||
const tiers = task?.tiers
|
||||
if (!Array.isArray(tiers) || tiers.length === 0) return false
|
||||
return tiers.some(tier => String(tier?.window || '').toLowerCase() === 'daily')
|
||||
}
|
||||
|
||||
function getTaskRuleTip(task) {
|
||||
if (hasDailyWindow(task)) return '按当日消费统计,次日重置'
|
||||
return ''
|
||||
}
|
||||
|
||||
function startCountdownTimer() {
|
||||
stopCountdownTimer()
|
||||
countdownTimer = setInterval(() => {
|
||||
nowMs.value = Date.now()
|
||||
}, 60 * 1000)
|
||||
}
|
||||
|
||||
function stopCountdownTimer() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function buildWindowedSubProgress(task, progressData) {
|
||||
const tierList = Array.isArray(task?.tiers) ? task.tiers : []
|
||||
const tierProgressList = Array.isArray(progressData?.tier_progress) ? progressData.tier_progress : []
|
||||
if (tierList.length === 0 || tierProgressList.length === 0) return []
|
||||
|
||||
const metricWhitelist = new Set(['order_amount', 'order_count'])
|
||||
const relevantTiers = tierList.filter(tier => metricWhitelist.has(tier?.metric))
|
||||
if (relevantTiers.length === 0) return []
|
||||
|
||||
const tierProgressMap = new Map(
|
||||
tierProgressList.map(tp => [Number(tp?.tier_id || 0), tp])
|
||||
)
|
||||
|
||||
const activityOrder = []
|
||||
const activitySeen = new Set()
|
||||
const activityStatsMap = new Map()
|
||||
|
||||
relevantTiers.forEach(tier => {
|
||||
const activityId = Number(tier?.activity_id || 0)
|
||||
if (!activitySeen.has(activityId)) {
|
||||
activitySeen.add(activityId)
|
||||
activityOrder.push(activityId)
|
||||
}
|
||||
|
||||
const current = activityStatsMap.get(activityId) || {
|
||||
activity_id: activityId,
|
||||
order_amount: 0,
|
||||
order_count: 0
|
||||
}
|
||||
|
||||
const tp = tierProgressMap.get(Number(tier?.id || 0))
|
||||
if (tp) {
|
||||
if (tier.metric === 'order_amount') {
|
||||
current.order_amount = Math.max(current.order_amount, Number(tp.order_amount || 0))
|
||||
} else if (tier.metric === 'order_count') {
|
||||
current.order_count = Math.max(current.order_count, Number(tp.order_count || 0))
|
||||
}
|
||||
}
|
||||
|
||||
activityStatsMap.set(activityId, current)
|
||||
})
|
||||
|
||||
return activityOrder.map(activityId => activityStatsMap.get(activityId)).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeSubProgress(task, progressData) {
|
||||
const rawList = Array.isArray(progressData?.sub_progress)
|
||||
const windowedList = buildWindowedSubProgress(task, progressData)
|
||||
const fallbackList = Array.isArray(progressData?.sub_progress)
|
||||
? progressData.sub_progress.map(item => ({
|
||||
activity_id: item.activity_id,
|
||||
order_amount: item.order_amount || 0,
|
||||
order_count: item.order_count || 0
|
||||
}))
|
||||
: []
|
||||
const rawList = windowedList.length > 0 ? [...windowedList] : fallbackList
|
||||
const hasGlobalTier = (task?.tiers || []).some(
|
||||
t => (t.activity_id || 0) === 0 && (t.metric === 'order_amount' || t.metric === 'order_count')
|
||||
)
|
||||
@ -383,16 +512,32 @@ function isTierClaimed(taskId, tierId) {
|
||||
return claimed.includes(tierId)
|
||||
}
|
||||
|
||||
// 是否可领取 - BUG修复:使用任务独立的进度数据
|
||||
// 是否可领取 - 优先使用 tier 级别窗口化进度(与后端 ClaimTier 一致)
|
||||
function isTierClaimable(task, tier) {
|
||||
const metric = tier.metric || ''
|
||||
const threshold = tier.threshold || 0
|
||||
const operator = tier.operator || '>='
|
||||
|
||||
// 获取该任务独立的进度数据
|
||||
|
||||
const progress = taskProgress[task.id] || {}
|
||||
|
||||
// FIX: 如果档位关联了特定活动,优先使用活动独立进度 (sub_progress)
|
||||
|
||||
// 优先使用 tier 级别窗口化进度(与后端 ClaimTier 使用同一数据源)
|
||||
if (progress.tierProgress && progress.tierProgress.length > 0) {
|
||||
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
|
||||
if (tp) {
|
||||
let current = 0
|
||||
if (metric === 'first_order') return tp.first_order || false
|
||||
else if (metric === 'order_count') current = tp.order_count || 0
|
||||
else if (metric === 'order_amount') current = tp.order_amount || 0
|
||||
else if (metric === 'invite_count') current = tp.invite_count || 0
|
||||
|
||||
if (operator === '>=') return current >= threshold
|
||||
if (operator === '==') return current === threshold
|
||||
if (operator === '>') return current > threshold
|
||||
return current >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用 subProgress 或全局进度
|
||||
if (tier.activity_id > 0) {
|
||||
if (progress.subProgress) {
|
||||
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
|
||||
@ -410,15 +555,12 @@ function isTierClaimable(task, tier) {
|
||||
if (operator === '>') return current > threshold
|
||||
return current >= threshold
|
||||
}
|
||||
// 其他指标暂时没有拆分,回退到总进度校验
|
||||
} else {
|
||||
// 没找到该活动的进度记录 -> 视为 0 ->不可领取
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用任务总进度
|
||||
let current = 0
|
||||
if (metric === 'first_order') {
|
||||
return progress.firstOrder || false
|
||||
@ -429,40 +571,44 @@ function isTierClaimable(task, tier) {
|
||||
} else if (metric === 'invite_count') {
|
||||
current = progress.inviteCount || 0
|
||||
}
|
||||
|
||||
|
||||
if (operator === '>=') return current >= threshold
|
||||
if (operator === '==') return current === threshold
|
||||
if (operator === '>') return current > threshold
|
||||
return current >= threshold
|
||||
}
|
||||
|
||||
// 获取进度文字 - BUG修复:使用任务独立的进度数据
|
||||
// 获取进度文字 - 优先使用 tier 级别窗口化进度
|
||||
function getTierProgressText(task, tier) {
|
||||
const metric = tier.metric || ''
|
||||
const threshold = tier.threshold || 0
|
||||
|
||||
// 获取该任务独立的进度数据
|
||||
|
||||
const progress = taskProgress[task.id] || {}
|
||||
|
||||
// FIX: 如果档位关联了特定活动,优先显示活动独立进度
|
||||
// 优先使用 tier 级别窗口化进度
|
||||
if (progress.tierProgress && progress.tierProgress.length > 0) {
|
||||
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
|
||||
if (tp) {
|
||||
if (metric === 'first_order') return tp.first_order ? '已完成' : '未完成'
|
||||
if (metric === 'order_amount') return `¥${(tp.order_amount || 0) / 100}/¥${threshold / 100}`
|
||||
if (metric === 'order_count') return `${tp.order_count || 0}/${threshold}`
|
||||
if (metric === 'invite_count') return `${tp.invite_count || 0}/${threshold}`
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:活动独立进度
|
||||
if (tier.activity_id > 0 && progress.subProgress) {
|
||||
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
|
||||
if (sub) {
|
||||
if (metric === 'order_amount') {
|
||||
const current = sub.order_amount || 0
|
||||
return `¥${current / 100}/¥${threshold / 100}`
|
||||
} else if (metric === 'order_count') {
|
||||
const current = sub.order_count || 0
|
||||
return `${current}/${threshold}`
|
||||
}
|
||||
if (metric === 'order_amount') return `¥${(sub.order_amount || 0) / 100}/¥${threshold / 100}`
|
||||
if (metric === 'order_count') return `${sub.order_count || 0}/${threshold}`
|
||||
} else {
|
||||
// 没找到记录 -> 0
|
||||
if (metric === 'order_amount') return `¥0/¥${threshold / 100}`
|
||||
return `0/${threshold}`
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用任务总进度
|
||||
|
||||
// 回退:任务总进度
|
||||
let current = 0
|
||||
if (metric === 'first_order') {
|
||||
return progress.firstOrder ? '已完成' : '未完成'
|
||||
@ -474,7 +620,7 @@ function getTierProgressText(task, tier) {
|
||||
} else if (metric === 'invite_count') {
|
||||
current = progress.inviteCount || 0
|
||||
}
|
||||
|
||||
|
||||
return `${current}/${threshold}`
|
||||
}
|
||||
|
||||
@ -561,7 +707,8 @@ async function fetchData() {
|
||||
orderAmount: p.order_amount || 0,
|
||||
inviteCount: p.invite_count || 0,
|
||||
firstOrder: p.first_order || false,
|
||||
subProgress: normalizedSubProgress // 新增:独立进度列表
|
||||
subProgress: normalizedSubProgress,
|
||||
tierProgress: p.tier_progress || []
|
||||
}
|
||||
|
||||
// 聚合进度指标 (取各任务返回的最大值 - 仅用于顶部统计卡片显示)
|
||||
@ -584,13 +731,18 @@ async function fetchData() {
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
nowMs.value = Date.now()
|
||||
startCountdownTimer()
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
stopCountdownTimer()
|
||||
})
|
||||
|
||||
// 计算普通档位进度条百分比
|
||||
function getTierProgressPercent(task, tier) {
|
||||
const metric = tier.metric || ''
|
||||
// first_order 是布尔值,不显示进度条
|
||||
if (metric === 'first_order') return ''
|
||||
|
||||
const threshold = tier.threshold || 0
|
||||
@ -600,27 +752,28 @@ function getTierProgressPercent(task, tier) {
|
||||
|
||||
let current = 0
|
||||
|
||||
// 如果档位关联了特定活动,从 subProgress 取值
|
||||
// 优先使用 tier 级别窗口化进度
|
||||
if (progress.tierProgress && progress.tierProgress.length > 0) {
|
||||
const tp = progress.tierProgress.find(t => t.tier_id === tier.id)
|
||||
if (tp) {
|
||||
if (metric === 'order_count') current = tp.order_count || 0
|
||||
else if (metric === 'order_amount') current = tp.order_amount || 0
|
||||
else if (metric === 'invite_count') current = tp.invite_count || 0
|
||||
return Math.min(current / threshold * 100, 100).toFixed(0) + '%'
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:活动独立进度
|
||||
if (tier.activity_id > 0 && progress.subProgress) {
|
||||
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
|
||||
if (sub) {
|
||||
if (metric === 'order_amount') {
|
||||
current = sub.order_amount || 0
|
||||
} else if (metric === 'order_count') {
|
||||
current = sub.order_count || 0
|
||||
}
|
||||
} else {
|
||||
current = 0
|
||||
if (metric === 'order_amount') current = sub.order_amount || 0
|
||||
else if (metric === 'order_count') current = sub.order_count || 0
|
||||
}
|
||||
} else {
|
||||
// 使用任务总进度
|
||||
if (metric === 'order_count') {
|
||||
current = progress.orderCount || 0
|
||||
} else if (metric === 'order_amount') {
|
||||
current = progress.orderAmount || 0
|
||||
} else if (metric === 'invite_count') {
|
||||
current = progress.inviteCount || 0
|
||||
}
|
||||
if (metric === 'order_count') current = progress.orderCount || 0
|
||||
else if (metric === 'order_amount') current = progress.orderAmount || 0
|
||||
else if (metric === 'invite_count') current = progress.inviteCount || 0
|
||||
}
|
||||
|
||||
return Math.min(current / threshold * 100, 100).toFixed(0) + '%'
|
||||
@ -893,6 +1046,41 @@ function formatAmount(cents) {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.task-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.task-time-range {
|
||||
font-size: 22rpx;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-time-countdown {
|
||||
font-size: 22rpx;
|
||||
color: $brand-primary;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.expired {
|
||||
color: $text-tertiary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.task-rule-tip {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
color: $text-tertiary;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
/* 独立进度条样式 */
|
||||
.sub-progress-list {
|
||||
display: flex;
|
||||
|
||||
@ -164,6 +164,12 @@
|
||||
"navigationStyle": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "synthesis/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "碎片合成"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -21,6 +21,9 @@
|
||||
<text class="tab-text">已申请发货</text>
|
||||
<text class="tab-count" v-if="shippedList.length > 0">({{ shippedList.length }})</text>
|
||||
</view>
|
||||
<view class="tab-item" :class="{ active: currentTab === 2 }" @tap="switchTab(2)">
|
||||
<text class="tab-text">碎片合成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 0: 待处理商品 -->
|
||||
@ -38,15 +41,18 @@
|
||||
|
||||
<view v-else class="inventory-grid">
|
||||
<view v-for="(item, index) in aggregatedList" :key="index" class="inventory-item">
|
||||
<view class="checkbox-area" @tap.stop="toggleSelect(item)">
|
||||
<view class="checkbox-area" @tap.stop="toggleSelect(item)" v-if="!item.is_fragment">
|
||||
<view class="checkbox" :class="{ checked: item.selected }"></view>
|
||||
</view>
|
||||
<view class="checkbox-area fragment-placeholder" v-else></view>
|
||||
<image :src="item.image" mode="aspectFill" class="item-image" @error="onImageError(index, 'aggregated')" />
|
||||
<view class="item-info">
|
||||
<text class="item-name">{{ item.name || '未命名道具' }}</text>
|
||||
<text class="item-tag-fragment" v-if="item.is_fragment">碎片</text>
|
||||
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
|
||||
<view class="item-actions">
|
||||
<text class="invite-btn" v-if="!item.selected" @tap.stop="onInvite(item)">邀请填写</text>
|
||||
<text class="invite-btn" v-if="!item.selected && !item.is_fragment" @tap.stop="onInvite(item)">邀请填写</text>
|
||||
<text class="synthesis-link" v-if="!item.selected && item.is_fragment" @tap.stop="switchTab(2)">去合成</text>
|
||||
<text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
|
||||
<view class="stepper" v-else @tap.stop>
|
||||
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
|
||||
@ -91,7 +97,7 @@
|
||||
<view class="shipment-status" :class="getStatusClass(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</view>
|
||||
<text class="shipment-cancel" v-if="Number(item.status) === 1 && item.batch_no" @tap="onCancelShipping(item)">撤销发货</text>
|
||||
<text class="shipment-cancel" v-if="Number(item.status) === 1 && item.batch_no && item.can_cancel" @tap="onCancelShipping(item)">撤销发货</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -143,6 +149,81 @@
|
||||
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
|
||||
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
|
||||
</block>
|
||||
|
||||
<!-- Tab 2: 碎片合成 -->
|
||||
<block v-if="currentTab === 2">
|
||||
<view v-if="synthLoading" class="status-text">加载中...</view>
|
||||
<view v-else-if="recipes.length === 0" class="status-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="getSynthFirstImage(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">
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 分享弹窗 -->
|
||||
<view class="share-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
|
||||
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
|
||||
@ -172,9 +253,11 @@
|
||||
<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 } from '@/api/appUser'
|
||||
import { getInventory, getProductDetail, redeemInventory, requestShipping, cancelShipping, listAddresses, getShipments, createAddressShare, createShippingFeeOrder } from '@/api/appUser'
|
||||
import { getSynthesisRecipes, doSynthesis } from '@/api/synthesis.js'
|
||||
import { vibrateShort } from '@/utils/vibrate.js'
|
||||
import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js'
|
||||
import { executePaymentFlow } from '@/utils/payment.js'
|
||||
// #ifdef MP-TOUTIAO
|
||||
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
|
||||
// #endif
|
||||
@ -195,6 +278,11 @@ const pageSize = ref(100)
|
||||
const hasMore = ref(true)
|
||||
const productMetaCache = new Map()
|
||||
|
||||
// Synthesis tab state
|
||||
const recipes = ref([])
|
||||
const synthLoading = ref(false)
|
||||
const synthesizing = ref(false)
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
|
||||
})
|
||||
@ -265,6 +353,8 @@ onShow(() => {
|
||||
const uid = uni.getStorageSync("user_id")
|
||||
if (currentTab.value === 1) {
|
||||
loadShipments(uid)
|
||||
} else if (currentTab.value === 2) {
|
||||
loadRecipes(uid)
|
||||
} else {
|
||||
loadInventory(uid) // 改为只加载第一页,后续由 onReachBottom 触发
|
||||
}
|
||||
@ -278,6 +368,8 @@ onPullDownRefresh(() => {
|
||||
if (currentTab.value === 1) {
|
||||
shippedList.value = []
|
||||
loadShipments(uid).finally(() => uni.stopPullDownRefresh())
|
||||
} else if (currentTab.value === 2) {
|
||||
loadRecipes(uid).finally(() => uni.stopPullDownRefresh())
|
||||
} else {
|
||||
aggregatedList.value = []
|
||||
loadInventory(uid).finally(() => uni.stopPullDownRefresh())
|
||||
@ -306,6 +398,8 @@ function switchTab(index) {
|
||||
const uid = uni.getStorageSync("user_id")
|
||||
if (currentTab.value === 1) {
|
||||
loadShipments(uid)
|
||||
} else if (currentTab.value === 2) {
|
||||
loadRecipes(uid)
|
||||
} else {
|
||||
loadInventory(uid) // 改为按需加载
|
||||
}
|
||||
@ -411,7 +505,7 @@ async function loadShipments(uid) {
|
||||
const products = s.products || []
|
||||
let productImages = []
|
||||
let productNames = []
|
||||
|
||||
|
||||
// 从 products 数组中提取图片和名称
|
||||
products.forEach(product => {
|
||||
if (product) {
|
||||
@ -422,13 +516,16 @@ async function loadShipments(uid) {
|
||||
productNames.push(product.name || product.title || '商品')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 如果没有获取到任何图片,使用默认图片
|
||||
if (productImages.length === 0) {
|
||||
productImages = ['/static/logo.png']
|
||||
productNames = ['未知商品']
|
||||
}
|
||||
|
||||
|
||||
// 判断是否可以撤销(48小时内且状态为待发货)
|
||||
const canCancel = checkCanCancel(s.status, s.created_at)
|
||||
|
||||
return {
|
||||
batch_no: s.batch_no || '',
|
||||
count: s.count ?? (Array.isArray(s.inventory_ids) ? s.inventory_ids.length : 0),
|
||||
@ -440,7 +537,8 @@ async function loadShipments(uid) {
|
||||
created_at: s.created_at || '',
|
||||
shipped_at: s.shipped_at || '',
|
||||
received_at: s.received_at || '',
|
||||
status: s.status || 1
|
||||
status: s.status || 1,
|
||||
can_cancel: canCancel
|
||||
}
|
||||
})
|
||||
|
||||
@ -496,7 +594,8 @@ async function loadInventory(uid) {
|
||||
selected: false,
|
||||
selectedCount: item.count || 0,
|
||||
has_shipment: item.has_shipment,
|
||||
updated_at: item.updated_at
|
||||
updated_at: item.updated_at,
|
||||
is_fragment: item.is_fragment || false
|
||||
}
|
||||
nextList.push(mappedItem)
|
||||
})
|
||||
@ -570,6 +669,60 @@ function changeCount(item, delta) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSynthFirstImage(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 getSynthReadyCount(recipe) {
|
||||
if (!recipe.materials) return 0
|
||||
return recipe.materials.filter(m => m.owned_count >= m.required_count).length
|
||||
}
|
||||
|
||||
async function loadRecipes(uid) {
|
||||
synthLoading.value = true
|
||||
const userId = uid || uni.getStorageSync('user_id')
|
||||
if (!userId) { synthLoading.value = false; return }
|
||||
try {
|
||||
const res = await getSynthesisRecipes(userId)
|
||||
recipes.value = res?.list || []
|
||||
} catch (e) {
|
||||
console.error('loadRecipes error', e)
|
||||
} finally {
|
||||
synthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSynthesize(recipe) {
|
||||
if (synthesizing.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
|
||||
})
|
||||
})
|
||||
} 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)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '合成失败', icon: 'none' })
|
||||
} finally {
|
||||
synthesizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRedeem() {
|
||||
vibrateShort()
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
@ -666,7 +819,55 @@ async function onShip() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 有默认地址,确认发货
|
||||
// 2. 有默认地址,判断是否需要支付运费
|
||||
const FREIGHT_THRESHOLD = 5
|
||||
const FREIGHT_FEE = 10
|
||||
|
||||
if (allIds.length > FREIGHT_THRESHOLD) {
|
||||
// 超过 5 件,需支付 10 元运费
|
||||
const confirmed = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '需支付运费',
|
||||
content: `共 ${allIds.length} 件商品,超过 ${FREIGHT_THRESHOLD} 件需支付 ¥${FREIGHT_FEE}.00 运费,确认继续?`,
|
||||
confirmText: '去支付',
|
||||
cancelText: '取消',
|
||||
success: (res) => resolve(res.confirm)
|
||||
})
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
try {
|
||||
await executePaymentFlow({
|
||||
createOrder: () => createShippingFeeOrder(user_id, allIds),
|
||||
openid: uni.getStorageSync('openid')
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
if (e?.cancelled) return
|
||||
uni.showToast({ title: e?.message || '支付失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.hideLoading()
|
||||
|
||||
// 支付成功后发货
|
||||
uni.showLoading({ title: '提交中...' })
|
||||
try {
|
||||
await requestShipping(user_id, allIds)
|
||||
uni.showToast({ title: '申请成功', icon: 'success' })
|
||||
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.showModal({
|
||||
title: '确认发货',
|
||||
content: `共 ${allIds.length} 件物品,确认申请发货?`,
|
||||
@ -692,10 +893,39 @@ async function onShip() {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否可以撤销发货(48小时内)
|
||||
function checkCanCancel(status, createdAt) {
|
||||
// 只有待发货状态(status=1)才能撤销
|
||||
if (Number(status) !== 1) {
|
||||
return false
|
||||
}
|
||||
// 没有创建时间,默认允许撤销(兼容旧数据)
|
||||
if (!createdAt) {
|
||||
return true
|
||||
}
|
||||
const created = new Date(createdAt).getTime()
|
||||
const now = Date.now()
|
||||
const diffHours = (now - created) / (1000 * 60 * 60)
|
||||
// 48小时内可以撤销
|
||||
return diffHours <= 48
|
||||
}
|
||||
|
||||
function onCancelShipping(shipment) {
|
||||
const user_id = uni.getStorageSync('user_id')
|
||||
const batchNo = shipment && shipment.batch_no
|
||||
if (!user_id || !batchNo) return
|
||||
|
||||
// 前端再次检查48小时限制
|
||||
if (!checkCanCancel(shipment.status, shipment.created_at)) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '发货申请超过48小时,不允许撤销,需要撤销发货请联系客服',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '撤销发货',
|
||||
content: `确认不再发货,并撤销发货单 ${batchNo} 吗?`,
|
||||
@ -711,7 +941,9 @@ function onCancelShipping(shipment) {
|
||||
shippedList.value = []
|
||||
await loadShipments(user_id)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
|
||||
// 后端返回的错误信息可能包含"联系客服"提示
|
||||
const msg = e?.message || '取消失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
@ -1402,4 +1634,297 @@ function onCopyShareLink() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-tag-fragment {
|
||||
display: inline-block;
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(135deg, #ff9800, #ff5722);
|
||||
color: #fff;
|
||||
margin-left: 8rpx;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.synthesis-link {
|
||||
font-size: 24rpx;
|
||||
color: $brand-primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fragment-placeholder {
|
||||
width: 40rpx;
|
||||
}
|
||||
|
||||
/* ── 碎片合成 Tab 样式 ── */
|
||||
.recipe-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx $spacing-lg;
|
||||
margin-top: 108rpx;
|
||||
}
|
||||
|
||||
.recipe-ticket {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-card;
|
||||
animation: fadeInUp 0.5s ease-out backwards;
|
||||
|
||||
&.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;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.ready-hint {
|
||||
font-size: 20rpx;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.synth-btn {
|
||||
height: 56rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.btn-ready {
|
||||
background: $gradient-brand;
|
||||
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.3);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-locked {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.btn-ready & { color: #fff; }
|
||||
.btn-locked & { color: $text-tertiary; }
|
||||
}
|
||||
|
||||
.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%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -778,9 +778,9 @@ export default {
|
||||
.card-match .card-title-small { color: $accent-pink; }
|
||||
|
||||
.card-tower {
|
||||
background: linear-gradient(135deg, #E0C3FC 0%, #8EC5FC 100%); /* 梦幻紫蓝 */
|
||||
background: linear-gradient(135deg, #FFE0CC 0%, #FFCBA4 100%); /* 品牌橙暖色 */
|
||||
}
|
||||
.card-tower .card-title-small { color: $accent-purple; }
|
||||
.card-tower .card-title-small { color: $brand-primary; }
|
||||
|
||||
.card-more {
|
||||
background: linear-gradient(135deg, $bg-secondary 0%, #E5E7EB 100%); /* 金属灰 */
|
||||
|
||||
@ -587,13 +587,19 @@ onUnmounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
background-color: #f7f8fa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shop-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -606,12 +612,19 @@ onUnmounted(() => {
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 160rpx;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
.sidebar-scroll { height: 100%; }
|
||||
.sidebar-list { padding: 40rpx 0; }
|
||||
.sidebar-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-list {
|
||||
padding: 40rpx 0 calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.sidebar-item {
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
@ -646,6 +659,7 @@ onUnmounted(() => {
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user