This commit is contained in:
win 2026-03-20 00:57:42 +08:00
parent bdd329eb15
commit fd252efae1
13 changed files with 1242 additions and 295 deletions

View File

@ -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 } }) 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) { export function cancelShipping(user_id, batch_no) {
return authRequest({ url: `/api/app/users/${user_id}/inventory/cancel-shipping`, method: 'POST', data: { 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 } }) 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) { export function redeemCoupon(user_id, code) {
return authRequest({ url: `/api/app/users/${user_id}/coupons/redeem`, method: 'POST', data: { code } }) return authRequest({ url: `/api/app/users/${user_id}/coupons/redeem`, method: 'POST', data: { code } })
} }

View File

@ -427,7 +427,7 @@ function handleClose() {
.title { .title {
font-size: 38rpx; font-size: 38rpx;
font-weight: 800; font-weight: 800;
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%); background: $gradient-brand;
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@ -451,7 +451,7 @@ function handleClose() {
transition: all 0.2s ease; transition: all 0.2s ease;
&:active { &:active {
color: #667EEA; color: $brand-primary;
transform: rotate(90deg); transform: rotate(90deg);
} }
} }
@ -500,7 +500,7 @@ function handleClose() {
left: 0; left: 0;
right: 0; right: 0;
height: 6rpx; height: 6rpx;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%); background: $gradient-brand;
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
} }
@ -607,7 +607,7 @@ function handleClose() {
} }
.btn-buy { .btn-buy {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%); background: $gradient-brand;
color: #FFF; color: #FFF;
font-size: 26rpx; font-size: 26rpx;
padding: 0 28rpx; padding: 0 28rpx;
@ -657,7 +657,7 @@ function handleClose() {
line-height: 44rpx; line-height: 44rpx;
text-align: center; text-align: center;
font-size: 32rpx; font-size: 32rpx;
color: #667EEA; color: $brand-primary;
font-weight: 600; font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -673,8 +673,8 @@ function handleClose() {
color: #9CA3AF; color: #9CA3AF;
&:active { &:active {
color: #667EEA; color: $brand-primary;
background: rgba(102, 126, 234, 0.1); background: rgba($brand-primary, 0.1);
} }
} }
@ -699,9 +699,9 @@ function handleClose() {
// //
.package-item.selected { .package-item.selected {
border-color: #667EEA; border-color: $brand-primary;
background: linear-gradient(145deg, #F8F9FF 0%, #EEF0FF 100%); background: linear-gradient(145deg, #FFF8F4 0%, #FFF0E6 100%);
box-shadow: 0 4rpx 20rpx rgba(102, 126, 234, 0.2); box-shadow: 0 4rpx 20rpx rgba($brand-primary, 0.2);
&::before { &::before {
opacity: 1; opacity: 1;
@ -826,7 +826,7 @@ function handleClose() {
} }
.btn-checkout { .btn-checkout {
background: linear-gradient(135deg, #667EEA 0%, #764BA2 100%); background: $gradient-brand;
color: #FFF; color: #FFF;
font-size: 32rpx; font-size: 32rpx;
padding: 0 48rpx; padding: 0 48rpx;

View File

@ -41,7 +41,9 @@
] ]
}, },
/* ios */ /* ios */
"ios" : {}, "ios" : {
"dSYMs" : false
},
/* SDK */ /* SDK */
"sdkConfigs" : {} "sdkConfigs" : {}
} }

View File

@ -2582,8 +2582,8 @@ onLoad((opts) => {
color: #fff; color: #fff;
&.btn-primary { &.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: $gradient-brand;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4); box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.4);
} }
&.btn-secondary { &.btn-secondary {

View File

@ -110,7 +110,7 @@ export default {
const targetCode = code || this.gameCode const targetCode = code || this.gameCode
if (targetCode !== 'minesweeper_free' && this.ticketCount <= 0) return if (targetCode !== 'minesweeper_free' && this.ticketCount <= 0) return
if (this.entering) return if (this.entering) return
this.entering = true this.entering = true
try { try {
const res = await authRequest({ const res = await authRequest({
@ -120,16 +120,10 @@ export default {
game_code: targetCode game_code: targetCode
} }
}) })
// const clientUrl = res.client_url || 'https://kdy.1024tool.vip'
const clientUrl = 'http://localhost:8081/'
const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws' const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws'
const userInfo = uni.getStorageSync('user_info') || {}
const nickname = userInfo.nickname || userInfo.name || '玩家'
const gameUrl = `${clientUrl}?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}&game_type=${encodeURIComponent(targetCode)}&nickname=${encodeURIComponent(nickname)}`
console.log('=== 游戏URL(复制到Chrome打开) ===', gameUrl)
uni.navigateTo({ uni.navigateTo({
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}` url: `/pages-game/game/minesweeper/play?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}`
}) })
} catch (e) { } catch (e) {
uni.showToast({ uni.showToast({
@ -151,12 +145,9 @@ export default {
} }
}) })
// const clientUrl = res.client_url || 'https://kdy.1024tool.vip'
const clientUrl = 'http://localhost:8081/'
const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws' const nakamaServer = res.nakama_server || 'wss://kdy.1024tool.vip/ws'
const gameUrl = `${clientUrl}?game_token=${encodeURIComponent(res.game_token)}&nakama_server=${encodeURIComponent(nakamaServer)}&nakama_key=${encodeURIComponent(res.nakama_key)}&mode=spectate`
uni.navigateTo({ uni.navigateTo({
url: `/pages-game/game/webview?url=${encodeURIComponent(gameUrl)}` 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) { } catch (e) {
uni.showToast({ title: '无法获取对战列表', icon: 'none' }) uni.showToast({ title: '无法获取对战列表', icon: 'none' })

View File

@ -94,7 +94,6 @@
<!-- 优化后的按钮位置 --> <!-- 优化后的按钮位置 -->
<view class="coupon-action-wrapper" v-if="currentTab === 1"> <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)"> <view class="use-btn" @click.stop="onUseCoupon(item)">
<text class="btn-text">去使用</text> <text class="btn-text">去使用</text>
<view class="btn-shine"></view> <view class="btn-shine"></view>
@ -119,8 +118,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { getUserCoupons, transferCoupon } from '../../api/appUser' import { getUserCoupons } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js' import { vibrateShort } from '@/utils/vibrate.js'
const list = ref([]) const list = ref([])
@ -321,43 +320,6 @@ function onUseCoupon(item) {
// #endif // #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(() => { onLoad(() => {
fetchData() fetchData()
}) })
@ -717,17 +679,7 @@ onLoad(() => {
transform: translateY(-50%); transform: translateY(-50%);
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-end;
gap: 16rpx;
}
.transfer-link {
font-size: 22rpx;
color: $brand-primary;
text-decoration: underline;
opacity: 0.8;
&:active { opacity: 1; }
} }
.use-btn { .use-btn {

View File

@ -570,10 +570,10 @@ onLoad(() => {
} }
.use-btn { .use-btn {
background: linear-gradient(135deg, #4facfe, #00f2fe); background: $gradient-brand;
padding: 12rpx 28rpx; padding: 12rpx 28rpx;
border-radius: 40rpx; 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; position: relative;
overflow: hidden; overflow: hidden;
display: flex; display: flex;

View File

@ -1001,9 +1001,9 @@ function exportReceipt() {
gap: 12rpx; gap: 12rpx;
margin-top: $spacing-lg; margin-top: $spacing-lg;
padding: 20rpx 32rpx; padding: 20rpx 32rpx;
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); background: $gradient-brand;
border-radius: 40rpx; 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 { &:active {
transform: scale(0.96); transform: scale(0.96);

View File

@ -1,56 +1,125 @@
<template> <template>
<view class="synthesis-wrap"> <view class="page-container">
<view class="page-title">碎片合成</view> <!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view v-if="loading" class="status-text">加载中...</view> <!-- 装饰光球 -->
<view v-else-if="recipes.length === 0" class="status-text">暂无可用的合成配方</view> <view class="orb orb-1"></view>
<view class="orb orb-2"></view>
<view v-for="recipe in recipes" :key="recipe.id" class="recipe-card"> <view class="header-area">
<!-- 目标商品 --> <view class="page-title">碎片合成</view>
<view class="target-section"> <view class="page-subtitle">Fragment Synthesis</view>
<image v-if="recipe.target_product" :src="getFirstImage(recipe.target_product.images_json)" mode="aspectFill" class="target-image" /> </view>
<view class="target-info">
<text class="target-name">{{ recipe.target_product?.name || '目标商品' }}</text> <scroll-view
<text class="recipe-name">{{ recipe.name }}</text> scroll-y
<text class="recipe-desc" v-if="recipe.description">{{ recipe.description }}</text> class="content-scroll"
</view> refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view> </view>
<!-- 材料列表 --> <!-- 空状态 -->
<view class="materials-section"> <view v-else-if="recipes.length === 0" class="empty-state">
<text class="section-label">所需材料</text> <text class="empty-icon">🧩</text>
<view class="materials-grid"> <text class="empty-text">暂无可用的合成配方</text>
<view v-for="(mat, idx) in recipe.materials" :key="idx" class="material-item"> <text class="empty-hint">敬请期待更多合成方案</text>
<image :src="mat.image || '/static/placeholder.png'" mode="aspectFill" class="material-image" /> </view>
<text class="material-name">{{ mat.name }}</text>
<view class="material-count" :class="{ enough: mat.owned_count >= mat.required_count }"> <!-- 配方卡片列表 -->
<text>{{ mat.owned_count }}</text> <view v-else class="recipe-list">
<text class="count-sep">/</text> <view
<text>{{ mat.required_count }}</text> 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>
</view> </view>
</view> </view>
</scroll-view>
<!-- 合成按钮 -->
<button
class="synthesis-btn"
:class="{ disabled: !recipe.can_synthesize }"
:disabled="!recipe.can_synthesize || synthesizing"
@tap="onSynthesize(recipe)"
>
{{ recipe.can_synthesize ? '立即合成' : '材料不足' }}
</button>
</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getSynthesisRecipes, doSynthesis } from '../../api/synthesis.js' import { getSynthesisRecipes, doSynthesis } from '../../api/synthesis.js'
const loading = ref(true) const loading = ref(true)
const synthesizing = ref(false) const synthesizing = ref(false)
const isRefreshing = ref(false)
const recipes = ref([]) const recipes = ref([])
function getFirstImage(imagesJson) { function getFirstImage(imagesJson) {
@ -63,6 +132,16 @@ function getFirstImage(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() { async function loadRecipes() {
loading.value = true loading.value = true
const userId = uni.getStorageSync('user_id') const userId = uni.getStorageSync('user_id')
@ -80,6 +159,12 @@ async function loadRecipes() {
} }
} }
async function onRefresh() {
isRefreshing.value = true
await loadRecipes()
isRefreshing.value = false
}
async function onSynthesize(recipe) { async function onSynthesize(recipe) {
if (synthesizing.value || !recipe.can_synthesize) return if (synthesizing.value || !recipe.can_synthesize) return
try { try {
@ -108,11 +193,10 @@ async function onSynthesize(recipe) {
} }
} }
onMounted(() => { onLoad(() => {
loadRecipes() loadRecipes()
}) })
//
const onShow = () => { const onShow = () => {
if (!loading.value) loadRecipes() if (!loading.value) loadRecipes()
} }
@ -120,155 +204,413 @@ defineExpose({ onShow })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.synthesis-wrap { .page-container {
min-height: 100vh; min-height: 100vh;
padding: 24rpx; background: $bg-page;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); 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 { .page-title {
font-size: 36rpx; font-size: 48rpx;
font-weight: bold; font-weight: 900;
color: #fff; color: $text-main;
text-align: center; margin-bottom: 8rpx;
padding: 20rpx 0 30rpx; letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
} }
.status-text { .page-subtitle {
text-align: center; font-size: 24rpx;
color: rgba(255, 255, 255, 0.5); color: $text-tertiary;
padding: 100rpx 0; text-transform: uppercase;
font-size: 28rpx; letter-spacing: 2rpx;
font-weight: 600;
} }
.recipe-card { /* 滚动区 */
background: rgba(255, 255, 255, 0.08); .content-scroll {
border-radius: 24rpx; height: calc(100vh - 220rpx);
padding: 30rpx; position: relative;
margin-bottom: 24rpx; z-index: 1;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
} }
.target-section { /* 加载 */
display: flex; .loading-state {
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.target-image {
width: 140rpx;
height: 140rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.1);
}
.target-info {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6rpx; align-items: center;
} justify-content: center;
padding: 120rpx $spacing-lg;
.target-name { color: $text-tertiary;
font-size: 32rpx; font-size: 26rpx;
font-weight: bold;
color: #fff;
}
.recipe-name {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
.recipe-desc {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.materials-section {
margin-bottom: 24rpx;
}
.section-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 16rpx;
display: block;
}
.materials-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx; gap: 16rpx;
} }
.material-item { /* 空状态 */
.empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 160rpx; justify-content: center;
padding: 16rpx; padding: 120rpx 0;
background: rgba(255, 255, 255, 0.05);
border-radius: 16rpx;
border: 1px solid rgba(255, 255, 255, 0.08);
} }
.material-image { .empty-icon {
width: 100rpx; font-size: 80rpx;
height: 100rpx; margin-bottom: 20rpx;
border-radius: 12rpx;
margin-bottom: 8rpx;
background: rgba(255, 255, 255, 0.1);
} }
.material-name { .empty-text {
font-size: 22rpx; color: $text-tertiary;
color: rgba(255, 255, 255, 0.8); font-size: 28rpx;
text-align: center; margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
} }
.material-count { .empty-hint {
color: $text-tertiary;
font-size: 24rpx; font-size: 24rpx;
color: #ff5252; opacity: 0.6;
font-weight: bold; }
margin-top: 4rpx;
&.enough { /* 配方列表 */
color: #4caf50; .recipe-list {
} display: flex;
flex-direction: column;
gap: 24rpx;
padding: 0 $spacing-lg $spacing-xl;
}
.count-sep { /* 票券式卡片 */
margin: 0 2rpx; .recipe-ticket {
color: rgba(255, 255, 255, 0.4); 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);
} }
} }
.synthesis-btn { @keyframes fadeInUp {
width: 100%; from { opacity: 0; transform: translateY(24rpx); }
height: 80rpx; to { opacity: 1; transform: translateY(0); }
border-radius: 40rpx; }
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; /* 左侧商品区 */
font-size: 30rpx; .ticket-left {
font-weight: bold; width: 200rpx;
border: none; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
}
&.disabled { .product-img {
background: rgba(255, 255, 255, 0.15); width: 100%;
color: rgba(255, 255, 255, 0.4); 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 {
&:active:not(.disabled) { color: $text-tertiary;
opacity: 0.85;
} }
} }
/* 分割线(票券缺口效果) */
.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> </style>

View File

@ -69,6 +69,13 @@
<view class="task-meta"> <view class="task-meta">
<text class="task-name">{{ task.name }}</text> <text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</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 时显示) --> <!-- 新增独立进度展示 (当存在 sub_progress 时显示) -->
<view class="sub-progress-list" v-if="taskProgress[task.id]?.subProgress?.length > 0"> <view class="sub-progress-list" v-if="taskProgress[task.id]?.subProgress?.length > 0">
@ -161,15 +168,18 @@
<script setup> <script setup>
import { ref, reactive } from 'vue' 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 { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
import { vibrateShort } from '@/utils/vibrate.js' import { vibrateShort } from '@/utils/vibrate.js'
import { parseTimeMs } from '@/utils/format.js'
const tasks = ref([]) const tasks = ref([])
const loading = ref(false) const loading = ref(false)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const expandedTasks = reactive({}) const expandedTasks = reactive({})
const claiming = reactive({}) const claiming = reactive({})
const nowMs = ref(Date.now())
let countdownTimer = null
// ( - ) // ( - )
const userProgress = reactive({ const userProgress = reactive({
@ -219,14 +229,133 @@ function getTaskIcon(task) {
return '⭐' 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) { 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 => ({ ? progressData.sub_progress.map(item => ({
activity_id: item.activity_id, activity_id: item.activity_id,
order_amount: item.order_amount || 0, order_amount: item.order_amount || 0,
order_count: item.order_count || 0 order_count: item.order_count || 0
})) }))
: [] : []
const rawList = windowedList.length > 0 ? [...windowedList] : fallbackList
const hasGlobalTier = (task?.tiers || []).some( const hasGlobalTier = (task?.tiers || []).some(
t => (t.activity_id || 0) === 0 && (t.metric === 'order_amount' || t.metric === 'order_count') 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) return claimed.includes(tierId)
} }
// - BUG使 // - 使 tier ClaimTier
function isTierClaimable(task, tier) { function isTierClaimable(task, tier) {
const metric = tier.metric || '' const metric = tier.metric || ''
const threshold = tier.threshold || 0 const threshold = tier.threshold || 0
const operator = tier.operator || '>=' const operator = tier.operator || '>='
//
const progress = taskProgress[task.id] || {} 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 (tier.activity_id > 0) {
if (progress.subProgress) { if (progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id) 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 if (operator === '>') return current > threshold
return current >= threshold return current >= threshold
} }
// 退
} else { } else {
// -> 0 ->
return false return false
} }
} }
} }
// 退使
let current = 0 let current = 0
if (metric === 'first_order') { if (metric === 'first_order') {
return progress.firstOrder || false return progress.firstOrder || false
@ -429,40 +571,44 @@ function isTierClaimable(task, tier) {
} else if (metric === 'invite_count') { } else if (metric === 'invite_count') {
current = progress.inviteCount || 0 current = progress.inviteCount || 0
} }
if (operator === '>=') return current >= threshold if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold if (operator === '==') return current === threshold
if (operator === '>') return current > threshold if (operator === '>') return current > threshold
return current >= threshold return current >= threshold
} }
// - BUG使 // - 使 tier
function getTierProgressText(task, tier) { function getTierProgressText(task, tier) {
const metric = tier.metric || '' const metric = tier.metric || ''
const threshold = tier.threshold || 0 const threshold = tier.threshold || 0
//
const progress = taskProgress[task.id] || {} 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) { if (tier.activity_id > 0 && progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id) const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
if (sub) { if (sub) {
if (metric === 'order_amount') { if (metric === 'order_amount') return `¥${(sub.order_amount || 0) / 100}${threshold / 100}`
const current = sub.order_amount || 0 if (metric === 'order_count') return `${sub.order_count || 0}/${threshold}`
return `¥${current / 100}${threshold / 100}`
} else if (metric === 'order_count') {
const current = sub.order_count || 0
return `${current}/${threshold}`
}
} else { } else {
// -> 0
if (metric === 'order_amount') return `¥0/¥${threshold / 100}` if (metric === 'order_amount') return `¥0/¥${threshold / 100}`
return `0/${threshold}` return `0/${threshold}`
} }
} }
// 退使 // 退
let current = 0 let current = 0
if (metric === 'first_order') { if (metric === 'first_order') {
return progress.firstOrder ? '已完成' : '未完成' return progress.firstOrder ? '已完成' : '未完成'
@ -474,7 +620,7 @@ function getTierProgressText(task, tier) {
} else if (metric === 'invite_count') { } else if (metric === 'invite_count') {
current = progress.inviteCount || 0 current = progress.inviteCount || 0
} }
return `${current}/${threshold}` return `${current}/${threshold}`
} }
@ -561,7 +707,8 @@ async function fetchData() {
orderAmount: p.order_amount || 0, orderAmount: p.order_amount || 0,
inviteCount: p.invite_count || 0, inviteCount: p.invite_count || 0,
firstOrder: p.first_order || false, firstOrder: p.first_order || false,
subProgress: normalizedSubProgress // subProgress: normalizedSubProgress,
tierProgress: p.tier_progress || []
} }
// ( - ) // ( - )
@ -584,13 +731,18 @@ async function fetchData() {
} }
onLoad(() => { onLoad(() => {
nowMs.value = Date.now()
startCountdownTimer()
fetchData() fetchData()
}) })
onUnload(() => {
stopCountdownTimer()
})
// //
function getTierProgressPercent(task, tier) { function getTierProgressPercent(task, tier) {
const metric = tier.metric || '' const metric = tier.metric || ''
// first_order
if (metric === 'first_order') return '' if (metric === 'first_order') return ''
const threshold = tier.threshold || 0 const threshold = tier.threshold || 0
@ -600,27 +752,28 @@ function getTierProgressPercent(task, tier) {
let current = 0 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) { if (tier.activity_id > 0 && progress.subProgress) {
const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id) const sub = progress.subProgress.find(s => s.activity_id === tier.activity_id)
if (sub) { if (sub) {
if (metric === 'order_amount') { if (metric === 'order_amount') current = sub.order_amount || 0
current = sub.order_amount || 0 else if (metric === 'order_count') current = sub.order_count || 0
} else if (metric === 'order_count') {
current = sub.order_count || 0
}
} else {
current = 0
} }
} else { } else {
// 使 if (metric === 'order_count') current = progress.orderCount || 0
if (metric === 'order_count') { else if (metric === 'order_amount') current = progress.orderAmount || 0
current = progress.orderCount || 0 else if (metric === 'invite_count') current = progress.inviteCount || 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) + '%' return Math.min(current / threshold * 100, 100).toFixed(0) + '%'
@ -893,6 +1046,41 @@ function formatAmount(cents) {
margin-bottom: 8rpx; 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 { .sub-progress-list {
display: flex; display: flex;

View File

@ -21,7 +21,7 @@
<text class="tab-text">已申请发货</text> <text class="tab-text">已申请发货</text>
<text class="tab-count" v-if="shippedList.length > 0">({{ shippedList.length }})</text> <text class="tab-count" v-if="shippedList.length > 0">({{ shippedList.length }})</text>
</view> </view>
<view class="tab-item" @tap="goSynthesis"> <view class="tab-item" :class="{ active: currentTab === 2 }" @tap="switchTab(2)">
<text class="tab-text">碎片合成</text> <text class="tab-text">碎片合成</text>
</view> </view>
</view> </view>
@ -52,7 +52,7 @@
<text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text> <text class="item-price" v-if="item.price">单价: ¥{{ item.price }}</text>
<view class="item-actions"> <view class="item-actions">
<text class="invite-btn" v-if="!item.selected && !item.is_fragment" @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="goSynthesis">去合成</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> <text class="item-count" v-if="!item.selected">x{{ item.count || 1 }}</text>
<view class="stepper" v-else @tap.stop> <view class="stepper" v-else @tap.stop>
<text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text> <text class="step-btn minus" @tap.stop="changeCount(item, -1)">-</text>
@ -149,6 +149,81 @@
<view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view> <view v-if="loading && shippedList.length > 0" class="loading-more">加载更多...</view>
<view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view> <view v-if="!hasMore && shippedList.length > 0" class="no-more">没有更多了</view>
</block> </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-mask" v-if="showSharePopup" @tap="showSharePopup = false" @touchmove.stop></view>
<view class="share-popup glass-card" :class="{ 'show': showSharePopup }"> <view class="share-popup glass-card" :class="{ 'show': showSharePopup }">
@ -178,9 +253,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onShow, onReachBottom, onShareAppMessage, onPullDownRefresh } from '@dcloudio/uni-app' 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 { vibrateShort } from '@/utils/vibrate.js'
import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js' import { checkPhoneBound, checkPhoneBoundSync } from '@/utils/checkPhone.js'
import { executePaymentFlow } from '@/utils/payment.js'
// #ifdef MP-TOUTIAO // #ifdef MP-TOUTIAO
import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue' import customTabBarToutiao from '@/components/app-tab-bar-toutiao.vue'
// #endif // #endif
@ -201,6 +278,11 @@ const pageSize = ref(100)
const hasMore = ref(true) const hasMore = ref(true)
const productMetaCache = new Map() const productMetaCache = new Map()
// Synthesis tab state
const recipes = ref([])
const synthLoading = ref(false)
const synthesizing = ref(false)
const totalCount = computed(() => { const totalCount = computed(() => {
return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0) return aggregatedList.value.reduce((sum, item) => sum + (item.count || 1), 0)
}) })
@ -271,6 +353,8 @@ onShow(() => {
const uid = uni.getStorageSync("user_id") const uid = uni.getStorageSync("user_id")
if (currentTab.value === 1) { if (currentTab.value === 1) {
loadShipments(uid) loadShipments(uid)
} else if (currentTab.value === 2) {
loadRecipes(uid)
} else { } else {
loadInventory(uid) // onReachBottom loadInventory(uid) // onReachBottom
} }
@ -284,6 +368,8 @@ onPullDownRefresh(() => {
if (currentTab.value === 1) { if (currentTab.value === 1) {
shippedList.value = [] shippedList.value = []
loadShipments(uid).finally(() => uni.stopPullDownRefresh()) loadShipments(uid).finally(() => uni.stopPullDownRefresh())
} else if (currentTab.value === 2) {
loadRecipes(uid).finally(() => uni.stopPullDownRefresh())
} else { } else {
aggregatedList.value = [] aggregatedList.value = []
loadInventory(uid).finally(() => uni.stopPullDownRefresh()) loadInventory(uid).finally(() => uni.stopPullDownRefresh())
@ -312,6 +398,8 @@ function switchTab(index) {
const uid = uni.getStorageSync("user_id") const uid = uni.getStorageSync("user_id")
if (currentTab.value === 1) { if (currentTab.value === 1) {
loadShipments(uid) loadShipments(uid)
} else if (currentTab.value === 2) {
loadRecipes(uid)
} else { } else {
loadInventory(uid) // loadInventory(uid) //
} }
@ -581,8 +669,58 @@ function changeCount(item, delta) {
} }
} }
function goSynthesis() { function getSynthFirstImage(imagesJson) {
uni.navigateTo({ url: '/pages-user/synthesis/index' }) 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() { async function onRedeem() {
@ -681,7 +819,55 @@ async function onShip() {
return 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({ uni.showModal({
title: '确认发货', title: '确认发货',
content: `${allIds.length} 件物品,确认申请发货?`, content: `${allIds.length} 件物品,确认申请发货?`,
@ -1462,11 +1648,283 @@ function onCopyShareLink() {
.synthesis-link { .synthesis-link {
font-size: 24rpx; font-size: 24rpx;
color: #667eea; color: $brand-primary;
font-weight: bold; font-weight: bold;
} }
.fragment-placeholder { .fragment-placeholder {
width: 40rpx; 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> </style>

View File

@ -778,9 +778,9 @@ export default {
.card-match .card-title-small { color: $accent-pink; } .card-match .card-title-small { color: $accent-pink; }
.card-tower { .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 { .card-more {
background: linear-gradient(135deg, $bg-secondary 0%, #E5E7EB 100%); /* 金属灰 */ background: linear-gradient(135deg, $bg-secondary 0%, #E5E7EB 100%); /* 金属灰 */

View File

@ -587,13 +587,19 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.page { .page {
height: 100vh; 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; background-color: #f7f8fa;
overflow: hidden; overflow: hidden;
} }
.shop-layout { .shop-layout {
display: flex; display: flex;
height: 100vh; flex: 1;
min-height: 0;
width: 100%; width: 100%;
} }
@ -606,12 +612,19 @@ onUnmounted(() => {
/* 侧边栏 */ /* 侧边栏 */
.sidebar { .sidebar {
width: 160rpx; width: 160rpx;
height: 100vh; height: 100%;
min-height: 0;
overflow: hidden;
border-right: 1rpx solid rgba(0, 0, 0, 0.05); border-right: 1rpx solid rgba(0, 0, 0, 0.05);
z-index: 10; z-index: 10;
} }
.sidebar-scroll { height: 100%; } .sidebar-scroll {
.sidebar-list { padding: 40rpx 0; } height: 100%;
min-height: 0;
}
.sidebar-list {
padding: 40rpx 0 calc(140rpx + env(safe-area-inset-bottom));
}
.sidebar-item { .sidebar-item {
height: 100rpx; height: 100rpx;
display: flex; display: flex;
@ -646,6 +659,7 @@ onUnmounted(() => {
/* 主内容区 */ /* 主内容区 */
.main-content { .main-content {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #fff; background: #fff;