Compare commits

...

6 Commits

Author SHA1 Message Date
win
fd252efae1 优化UI 2026-03-20 00:57:42 +08:00
bdd329eb15 feat(mini): add fragment synthesis page and cabinet fragment UX 2026-03-19 16:27:54 +08:00
3e20dd845a fix: 前端过滤 sub_status=expired 的优惠券
yifanshang/wuxianshang/duiduipeng 三个活动页面的 fetchCoupons
在赋值前过滤掉已过期的券,作为后端的防御层
2026-03-18 21:58:41 +08:00
bcbe7a9b29 feat(shipping): 前端添加48小时撤销限制
- 添加checkCanCancel函数判断是否在48小时内
- 超过48小时不显示撤销发货按钮
- 点击撤销时如超过48小时提示'需要撤销发货请联系客服'
2026-03-17 18:11:05 +08:00
be915a1507 fix: 赠送填写地址页强制登录,防止地址归属错误
- 未登录时弹窗引导登录后再填写
- onShow检测登录状态变化,登录后自动加载地址列表
- onSubmit增加登录检查防线
2026-03-15 13:18:38 +08:00
499ac1514e feat: H5扫雷游戏WebView对接优化
- 动态拼接游戏URL(client_url/nakama_server)
- 传递nickname参数给H5
- 添加游戏URL调试日志
2026-03-14 22:49:38 +08:00
19 changed files with 1494 additions and 172 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 } })
}
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
View 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 } })
}

View File

@ -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;

View File

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

View File

@ -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 {

View File

@ -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)

View File

@ -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 = []
}

View File

@ -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' })

View File

@ -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)

View File

@ -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

View File

@ -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 {

View File

@ -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;

View File

@ -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);

View 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>

View File

@ -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;

View File

@ -164,6 +164,12 @@
"navigationStyle": "default"
}
}
},
{
"path": "synthesis/index",
"style": {
"navigationBarTitleText": "碎片合成"
}
}
]
},

View File

@ -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>

View File

@ -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%); /* 金属灰 */

View File

@ -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;