feat: 新增我的优惠券、物品卡片、邀请、任务页面,并优化活动相关组件和页面。

This commit is contained in:
邹方成 2025-12-26 02:11:05 +08:00
parent d5527625bc
commit 7406f8b308
30 changed files with 4197 additions and 702 deletions

View File

@ -150,6 +150,14 @@ export function redeemProductByPoints(user_id, product_id, quantity) {
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } }) return authRequest({ url: `/api/app/users/${user_id}/points/redeem-product`, method: 'POST', data: { product_id, quantity } })
} }
export function redeemItemCardByPoints(user_id, item_card_id, quantity = 1) {
return authRequest({ url: `/api/app/users/${user_id}/points/redeem-item-card`, method: 'POST', data: { item_card_id, quantity } })
}
export function getStoreItems(kind = 'product', page = 1, page_size = 20) {
return authRequest({ url: '/api/app/store/items', method: 'GET', data: { kind, page, page_size } })
}
export function getTasks(page = 1, page_size = 20) { export function getTasks(page = 1, page_size = 20) {
return authRequest({ url: '/api/app/task-center/tasks', method: 'GET', data: { page, page_size } }) return authRequest({ url: '/api/app/task-center/tasks', method: 'GET', data: { page, page_size } })
} }
@ -158,6 +166,10 @@ export function getTaskProgress(task_id, user_id) {
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/progress/${user_id}`, method: 'GET' }) return authRequest({ url: `/api/app/task-center/tasks/${task_id}/progress/${user_id}`, method: 'GET' })
} }
export function claimTaskReward(task_id, user_id, tier_id) {
return authRequest({ url: `/api/app/task-center/tasks/${task_id}/claim/${user_id}`, method: 'POST', data: { tier_id } })
}
export function getShipments(user_id, page = 1, page_size = 20) { export function getShipments(user_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/users/${user_id}/shipments`, method: 'GET', data: { page, page_size } }) return authRequest({ url: `/api/app/users/${user_id}/shipments`, method: 'GET', data: { page, page_size } })
} }
@ -229,3 +241,15 @@ export function checkMatchingGame(game_id, total_pairs) {
data: { game_id, total_pairs } data: { game_id, total_pairs }
}) })
} }
/**
* 支付成功后获取游戏数据
* @param {string} game_id - 游戏ID
*/
export function getMatchingGameCards(game_id) {
return authRequest({
url: '/api/app/matching/cards',
method: 'GET',
data: { game_id }
})
}

View File

@ -25,7 +25,7 @@
</view> </view>
</view> </view>
<view class="action-bar"> <view class="action-bar" v-if="!hideActionBar">
<view class="selection-info" v-if="selectedItems.length > 0"> <view class="selection-info" v-if="selectedItems.length > 0">
已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置 已选 <text class="highlight">{{ selectedItems.length }}</text> 个位置
</view> </view>
@ -62,10 +62,11 @@ const props = defineProps({
issueId: { type: [String, Number], required: true }, issueId: { type: [String, Number], required: true },
pricePerDraw: { type: Number, default: 0 }, pricePerDraw: { type: Number, default: 0 },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
disabledText: { type: String, default: '' } disabledText: { type: String, default: '' },
hideActionBar: { type: Boolean, default: false } //
}) })
const emit = defineEmits(['payment-success']) const emit = defineEmits(['payment-success', 'selection-change'])
const choices = ref([]) const choices = ref([])
const loading = ref(false) const loading = ref(false)
@ -162,6 +163,9 @@ function handleSelect(item) {
} else { } else {
selectedItems.value.push(item) selectedItems.value.push(item)
} }
//
emit('selection-change', [...selectedItems.value])
} }
function handleBuy() { function handleBuy() {
@ -320,6 +324,13 @@ async function onPaymentConfirm(paymentData) {
} }
} }
} }
//
defineExpose({
handleRandomOne,
handleBuy,
selectedItems: () => selectedItems.value
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -74,14 +74,17 @@ const formattedPrice = computed(() => {
.header-card { .header-card {
margin: $spacing-xl $spacing-lg; margin: $spacing-xl $spacing-lg;
background: rgba($bg-card, 0.85); background: rgba($bg-card, 0.72);
backdrop-filter: blur(24rpx); backdrop-filter: blur(32rpx);
border-radius: $radius-xl; border-radius: $radius-xl;
padding: $spacing-lg; padding: $spacing-lg;
display: flex; display: flex;
align-items: center; align-items: center;
box-shadow: $shadow-card; box-shadow:
border: 1rpx solid rgba(255, 255, 255, 0.6); 0 1rpx 0 rgba(255,255,255,0.5) inset,
0 -1rpx 0 rgba(0,0,0,0.02) inset,
$shadow-card;
border: none;
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@ -106,15 +106,15 @@ defineProps({
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 700rpx; height: 900rpx;
z-index: 1; z-index: 1;
} }
.bg-image { .bg-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
filter: blur(30rpx) brightness(0.9); filter: blur(40rpx) brightness(0.85) saturate(1.1);
transform: scale(1.1); transform: scale(1.15);
} }
.bg-mask { .bg-mask {
@ -123,7 +123,16 @@ defineProps({
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(180deg, rgba($bg-page, 0.2) 0%, $bg-page 90%, $bg-page 100%); /* 6段式平滑过渡模拟ease-out曲线 */
background:
linear-gradient(180deg,
rgba($bg-page, 0) 0%,
rgba($bg-page, 0.05) 15%,
rgba($bg-page, 0.2) 35%,
rgba($bg-page, 0.5) 55%,
rgba($bg-page, 0.8) 70%,
$bg-page 82%
);
} }
.main-scroll { .main-scroll {

View File

@ -49,11 +49,13 @@ const staggerClass = computed(() => `stagger-${props.stagger}`)
/* Section Container - 与原始设计一致 */ /* Section Container - 与原始设计一致 */
.section-container { .section-container {
margin: 0 $spacing-lg $spacing-lg; margin: 0 $spacing-lg $spacing-lg;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.78);
border-radius: $radius-xl; border-radius: $radius-xl;
padding: $spacing-lg; padding: $spacing-lg;
box-shadow: $shadow-sm; box-shadow:
backdrop-filter: blur(10rpx); 0 1rpx 0 rgba(255,255,255,0.4) inset,
$shadow-sm;
backdrop-filter: blur(16rpx);
} }
/* Modern Tabs - 与原始设计完全一致 */ /* Modern Tabs - 与原始设计完全一致 */

View File

@ -0,0 +1,259 @@
<template>
<view v-if="visible" class="cabinet-overlay" @touchmove.stop.prevent>
<view class="cabinet-mask" @tap="close"></view>
<view class="cabinet-panel" @tap.stop>
<view class="cabinet-header">
<text class="cabinet-title">我的盒柜</text>
<text class="cabinet-close" @tap="close">×</text>
</view>
<scroll-view scroll-y class="cabinet-content">
<view v-if="loading" class="cabinet-loading">
<text class="loading-icon">📦</text>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="items.length === 0" class="cabinet-empty">
<text class="empty-icon">🎁</text>
<text class="empty-text">暂无物品</text>
<text class="empty-hint">参与活动获得奖品后会显示在这里</text>
</view>
<view v-else class="cabinet-grid">
<view v-for="item in displayItems" :key="item.id" class="cabinet-item">
<image class="item-image" :src="item.image" mode="aspectFill" />
<view class="item-info">
<text class="item-name">{{ item.name }}</text>
<text class="item-count">x{{ item.count }}</text>
</view>
</view>
</view>
<view v-if="hasMore" class="load-more" @tap="goFullCabinet">
<text>查看全部 {{ total }} 件物品</text>
<text class="arrow"></text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
activityId: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['update:visible'])
const loading = ref(false)
const items = ref([])
const total = ref(0)
const maxDisplay = 6 // 6
const displayItems = computed(() => items.value.slice(0, maxDisplay))
const hasMore = computed(() => items.value.length > maxDisplay || total.value > items.value.length)
function close() {
emit('update:visible', false)
}
function goFullCabinet() {
close()
uni.switchTab({ url: '/pages/cabinet/index' })
}
// API
async function loadItems() {
if (!props.activityId) return
loading.value = true
try {
// TODO: API
// const res = await getUserCabinetItems(props.activityId)
// items.value = res.items || []
// total.value = res.total || 0
//
await new Promise(r => setTimeout(r, 300))
items.value = []
total.value = 0
} catch (e) {
console.error('加载盒柜失败', e)
} finally {
loading.value = false
}
}
watch(() => props.visible, (v) => {
if (v) loadItems()
})
</script>
<style lang="scss" scoped>
.cabinet-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9000;
}
.cabinet-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.cabinet-panel {
position: absolute;
left: $spacing-lg;
right: $spacing-lg;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
max-height: 65vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.5);
overflow: hidden;
animation: slideUp 0.25s ease-out;
}
.cabinet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-lg;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
}
.cabinet-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
}
.cabinet-close {
font-size: 48rpx;
line-height: 1;
color: $text-tertiary;
padding: 0 10rpx;
}
.cabinet-content {
max-height: 50vh;
padding: $spacing-lg;
}
.cabinet-loading,
.cabinet-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx $spacing-lg;
}
.loading-icon,
.empty-icon {
font-size: 64rpx;
margin-bottom: $spacing-md;
}
.loading-text,
.empty-text {
font-size: $font-md;
color: $text-sub;
font-weight: 600;
}
.empty-hint {
font-size: $font-xs;
color: $text-tertiary;
margin-top: $spacing-xs;
}
.cabinet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $spacing-md;
}
.cabinet-item {
display: flex;
flex-direction: column;
align-items: center;
}
.item-image {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: $radius-md;
background: $bg-secondary;
margin-bottom: $spacing-xs;
}
.item-info {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.item-name {
font-size: $font-xs;
color: $text-main;
font-weight: 600;
@include text-ellipsis(1);
text-align: center;
width: 100%;
}
.item-count {
font-size: 22rpx;
color: $brand-primary;
font-weight: 700;
}
.load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: $spacing-lg 0;
margin-top: $spacing-md;
color: $brand-primary;
font-size: $font-sm;
font-weight: 600;
border-top: 1rpx solid rgba(0, 0, 0, 0.04);
}
.arrow {
font-size: 28rpx;
font-weight: 700;
}
@keyframes slideUp {
from {
transform: translateY(40rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<view> <view class="records-wrapper">
<view class="records-list" v-if="records && records.length"> <view class="records-list" v-if="records && records.length">
<view v-for="(item, idx) in records" :key="item.id || idx" class="record-item"> <view v-for="(item, idx) in records" :key="item.id || idx" class="record-item">
<image class="record-img" :src="item.image" mode="aspectFill" /> <image class="record-img" :src="item.image" mode="aspectFill" />
@ -12,9 +12,12 @@
</view> </view>
</view> </view>
</view> </view>
<view class="empty-state" v-else> <view class="empty-state-compact" v-else>
<text class="empty-icon">📝</text> <view class="empty-icon-wrap">
<text class="empty-text">{{ emptyText }}</text> <text class="empty-icon">🎁</text>
</view>
<text class="empty-title">{{ emptyText }}</text>
<text class="empty-hint">快来参与活动获得奖品吧</text>
</view> </view>
</view> </view>
</template> </template>
@ -87,21 +90,40 @@ defineProps({
color: $text-sub; color: $text-sub;
} }
.empty-state { /* 紧凑优雅的空状态 */
.empty-state-compact {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: $spacing-xl; padding: $spacing-lg $spacing-xl;
color: $text-sub; min-height: 200rpx;
}
.empty-icon-wrap {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($accent-gold, 0.1) 100%);
border-radius: 50%;
margin-bottom: $spacing-md;
} }
.empty-icon { .empty-icon {
font-size: 64rpx; font-size: 40rpx;
margin-bottom: $spacing-sm;
} }
.empty-text { .empty-title {
font-size: $font-sm; font-size: $font-md;
color: $text-sub;
font-weight: 600;
margin-bottom: 8rpx;
}
.empty-hint {
font-size: $font-xs;
color: $text-tertiary;
} }
</style> </style>

View File

@ -223,6 +223,7 @@ const rewardGroups = computed(() => {
justify-content: center; justify-content: center;
padding: $spacing-xl; padding: $spacing-xl;
color: $text-sub; color: $text-sub;
min-height: 300rpx; /* 防止切换时布局跳动 */
} }
.empty-icon { .empty-icon {

View File

@ -0,0 +1,125 @@
<template>
<view v-if="visible" class="rules-overlay" @touchmove.stop.prevent>
<view class="rules-mask" @tap="close"></view>
<view class="rules-panel" @tap.stop>
<view class="rules-header">
<text class="rules-title">{{ title }}</text>
<text class="rules-close" @tap="close">×</text>
</view>
<scroll-view scroll-y class="rules-content">
<!-- 使用 rich-text 渲染富文本 HTML -->
<rich-text v-if="content" class="rules-richtext" :nodes="content"></rich-text>
<view v-else class="rules-empty">暂无活动规则</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '活动规则'
},
content: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible'])
function close() {
emit('update:visible', false)
}
</script>
<style lang="scss" scoped>
.rules-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9000;
}
.rules-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10rpx);
}
.rules-panel {
position: absolute;
left: $spacing-lg;
right: $spacing-lg;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
max-height: 70vh;
background: rgba($bg-card, 0.95);
border-radius: $radius-xl;
box-shadow: $shadow-card;
border: 1rpx solid rgba(255, 255, 255, 0.5);
overflow: hidden;
animation: slideUp 0.25s ease-out;
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-lg;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
}
.rules-title {
font-size: $font-lg;
font-weight: 800;
color: $text-main;
}
.rules-close {
font-size: 48rpx;
line-height: 1;
color: $text-tertiary;
padding: 0 10rpx;
}
.rules-content {
max-height: 55vh;
padding: $spacing-lg;
}
.rules-richtext {
font-size: $font-sm;
color: $text-main;
line-height: 1.8;
}
.rules-empty {
text-align: center;
color: $text-sub;
padding: $spacing-xl;
font-size: $font-sm;
}
@keyframes slideUp {
from {
transform: translateY(40rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -42,6 +42,30 @@
"navigationBarTitleText": "积分记录" "navigationBarTitleText": "积分记录"
} }
}, },
{
"path": "pages/coupons/index",
"style": {
"navigationBarTitleText": "我的优惠券"
}
},
{
"path": "pages/item-cards/index",
"style": {
"navigationBarTitleText": "我的道具卡"
}
},
{
"path": "pages/invites/index",
"style": {
"navigationBarTitleText": "邀请记录"
}
},
{
"path": "pages/tasks/index",
"style": {
"navigationBarTitleText": "任务中心"
}
},
{ {
"path": "pages/orders/index", "path": "pages/orders/index",
"style": { "style": {

View File

@ -1,5 +1,6 @@
<template> <template>
<ActivityPageLayout :cover-url="coverUrl"> <ActivityPageLayout :cover-url="coverUrl">
<view class="bg-decoration"></view>
<template #header> <template #header>
<ActivityHeader <ActivityHeader
:title="detail.name || detail.title || '对对碰'" :title="detail.name || detail.title || '对对碰'"
@ -78,46 +79,69 @@
</template> </template>
<template #modals> <template #modals>
<!-- 游戏弹窗 --> <!-- 游戏弹窗 - 全屏沉浸式 -->
<view v-if="gameVisible" class="rewards-overlay" @touchmove.stop.prevent> <view v-if="gameVisible" class="game-overlay" @touchmove.stop.prevent>
<view class="rewards-mask" @tap="closeGame"></view> <view class="game-mask"></view>
<view class="rewards-panel" @tap.stop> <view class="game-fullscreen" @tap.stop>
<view class="rewards-header"> <!-- 顶部标题栏 -->
<text class="rewards-title">对对碰游戏</text> <view class="game-topbar">
<text class="rewards-close" @tap="closeGame">×</text> <text class="game-topbar-title">对对碰游戏</text>
<view class="game-close-btn" @tap="closeGame">
<text>×</text>
</view>
</view> </view>
<scroll-view scroll-y class="game-scroll-list">
<view v-if="gameLoading" class="game-empty-state">加载中...</view> <!-- 游戏信息 -->
<view v-else-if="gameError" class="game-empty-state">{{ gameError }}</view> <view class="game-stats glass-card">
<view v-else> <view class="stat-item">
<view class="game-info-card"> <text class="stat-label">总对数</text>
<view class="game-info-content"> <text class="stat-value">{{ totalPairs }}</text>
<view class="game-info-title">总对数{{ totalPairs }} 摸牌机会{{ chance }}</view> </view>
<view class="game-info-meta"> <view class="stat-item">
<text>牌组剩余 {{ deckRemaining }}</text> <text class="stat-label">摸牌机会</text>
<text v-if="selectedPositionText">位置 {{ selectedPositionText }}</text> <text class="stat-value">{{ chance }}</text>
<text v-if="gameIdText">ID {{ gameIdText }}</text> </view>
</view> <view class="stat-item">
</view> <text class="stat-label">牌组剩余</text>
</view> <text class="stat-value">{{ deckRemaining }}</text>
<view class="match-grid"> </view>
</view>
<!-- 游戏区域 -->
<view class="game-content">
<view v-if="gameLoading" class="game-loading">
<text class="loading-icon"></text>
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="gameError" class="game-error">
<text class="error-icon"></text>
<text class="error-text">{{ gameError }}</text>
</view>
<view v-else class="game-board glass-card">
<view class="match-grid-fullscreen">
<view <view
v-for="(cell, idx) in handGridCells" v-for="(cell, idx) in handGridCells"
:key="idx" :key="idx"
class="match-cell" class="match-cell-large"
:class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }" :class="{ empty: cell.empty, chosen: cell.isChosen, picked: cell.isPicked }"
@tap="() => onCellTap(cell)" @tap="() => onCellTap(cell)"
> >
<image v-if="cell.image" class="match-cell-img" :src="cell.image" mode="aspectFill" /> <image v-if="cell.image" class="match-cell-img-large" :src="cell.image" mode="aspectFill" />
<view v-else class="match-cell-img"></view> <view v-else class="match-cell-img-large"></view>
<text v-if="!cell.empty && cell.type" class="match-cell-type">{{ cell.type }}</text> <text v-if="!cell.empty && cell.type" class="match-cell-type-large">{{ cell.type }}</text>
</view> </view>
</view> </view>
</view> </view>
</scroll-view> </view>
<view class="flip-actions" style="padding: 20rpx 24rpx;">
<button class="close-btn" style="flex: 1;" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">摸牌</button> <!-- 底部操作栏 -->
<button class="close-btn" style="flex: 1; background: linear-gradient(135deg, #ff7a18, #ffb347); color: #fff;" @tap="advanceOne" :disabled="gameLoading">下一步</button> <view class="game-actions">
<button class="game-btn btn-secondary" @tap="manualDraw" :disabled="gameLoading || !canManualDraw">
<text>摸牌</text>
</button>
<button class="game-btn btn-primary" @tap="advanceOne" :disabled="gameLoading">
<text>下一步</text>
</button>
</view> </view>
</view> </view>
</view> </view>
@ -137,6 +161,15 @@
:showCards="true" :showCards="true"
@confirm="onPaymentConfirm" @confirm="onPaymentConfirm"
/> />
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
</template> </template>
</ActivityPageLayout> </ActivityPageLayout>
</template> </template>
@ -151,7 +184,9 @@ import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue' import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue' import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue' import RecordsList from '@/components/activity/RecordsList.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs } from '../../../api/appUser' import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, getUserCoupons, getItemCards, createWechatOrder, getMatchingCardTypes, createMatchingPreorder, checkMatchingGame, getIssueDrawLogs, getMatchingGameCards } from '../../../api/appUser'
const detail = ref({}) const detail = ref({})
const statusText = ref('') const statusText = ref('')
@ -173,6 +208,8 @@ const cardTypesLoading = ref(false)
const cardTypes = ref([]) const cardTypes = ref([])
const selectedCardTypeCode = ref('') const selectedCardTypeCode = ref('')
const rewardsVisible = ref(false) const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const resumeGame = ref(null) const resumeGame = ref(null)
const resumeIssueId = ref('') const resumeIssueId = ref('')
const hasResumeGame = computed(() => { const hasResumeGame = computed(() => {
@ -613,15 +650,11 @@ function closeRewardsPopup() {
} }
function showRules() { function showRules() {
uni.showModal({ rulesVisible.value = true
title: '活动规则',
content: detail.value.rules || '1. 选择卡牌类型进行对对碰\\n2. 每次抽取随机获得奖品\\n3. 奖池与概率以页面展示为准',
showCancel: false
})
} }
function goCabinet() { function goCabinet() {
uni.switchTab({ url: '/pages/cabinet/index' }) cabinetVisible.value = true
} }
function onPreviewBanner() { function onPreviewBanner() {
@ -823,6 +856,7 @@ function drawOne() {
function manualDraw() { function manualDraw() {
if (gameLoading.value) return if (gameLoading.value) return
if (!canManualDraw.value) return if (!canManualDraw.value) return
uni.vibrateShort({ type: 'light' })
drawOne() drawOne()
chance.value = Math.max(0, Number(chance.value || 0) - 1) chance.value = Math.max(0, Number(chance.value || 0) - 1)
pickedHandIndex.value = -1 pickedHandIndex.value = -1
@ -854,6 +888,7 @@ async function autoDrawIfStuck() {
async function onCellTap(cell) { async function onCellTap(cell) {
if (gameLoading.value) return if (gameLoading.value) return
if (!cell || cell.empty) return if (!cell || cell.empty) return
uni.vibrateShort({ type: 'light' })
const hi = Number(cell.handIndex) const hi = Number(cell.handIndex)
if (!Number.isFinite(hi) || hi < 0) return if (!Number.isFinite(hi) || hi < 0) return
@ -912,6 +947,7 @@ async function finishAndReport() {
async function advanceOne() { async function advanceOne() {
if (gameLoading.value) return if (gameLoading.value) return
uni.vibrateShort({ type: 'light' })
const entry = gameEntry.value || null const entry = gameEntry.value || null
const gameId = entry && entry.game_id ? String(entry.game_id) : '' const gameId = entry && entry.game_id ? String(entry.game_id) : ''
if (!gameId) return if (!gameId) return
@ -969,6 +1005,7 @@ async function autoRun() {
} }
async function onParticipate() { async function onParticipate() {
uni.vibrateShort({ type: 'medium' })
const aid = activityId.value || '' const aid = activityId.value || ''
const iid = currentIssueId.value || '' const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return } if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
@ -1014,6 +1051,7 @@ async function applyResumeEntry(entry) {
} }
async function onResumeGame() { async function onResumeGame() {
uni.vibrateShort({ type: 'medium' })
const aid = activityId.value || '' const aid = activityId.value || ''
const latest = syncResumeGame(aid) const latest = syncResumeGame(aid)
if (!latest || !latest.entry || !latest.entry.game_id) return if (!latest || !latest.entry || !latest.entry.game_id) return
@ -1060,7 +1098,7 @@ async function doDraw() {
return return
} }
// 1. createMatchingPreorder // 1. createMatchingPreorder
const preRes = await createMatchingPreorder({ const preRes = await createMatchingPreorder({
issue_id: Number(iid), issue_id: Number(iid),
position: String(selectedCardType.value.code || ''), position: String(selectedCardType.value.code || ''),
@ -1069,12 +1107,12 @@ async function doDraw() {
}) })
if (!preRes) throw new Error('创建订单失败') if (!preRes) throw new Error('创建订单失败')
// 2. // 2. IDall_cards
const orderNo = preRes.order_no || preRes.data?.order_no || preRes.result?.order_no || preRes.orderNo const orderNo = preRes.order_no || preRes.data?.order_no || preRes.result?.order_no || preRes.orderNo
if (!orderNo) throw new Error('未获取到订单号') if (!orderNo) throw new Error('未获取到订单号')
const gameId = preRes.game_id || preRes.data?.game_id || preRes.result?.game_id || preRes.gameId const gameId = preRes.game_id || preRes.data?.game_id || preRes.result?.game_id || preRes.gameId
const allCards = normalizeAllCards(preRes.all_cards || preRes.data?.all_cards || preRes.result?.all_cards || []) if (!gameId) throw new Error('未获取到游戏ID')
// 3. // 3.
uni.showLoading({ title: '拉起支付...' }) uni.showLoading({ title: '拉起支付...' })
@ -1092,20 +1130,26 @@ async function doDraw() {
}) })
}) })
// 4. // 4.
if (gameId) { uni.showLoading({ title: '加载游戏...' })
writeMatchingGameCacheEntry(aid, iid, { const cardsRes = await getMatchingGameCards(gameId)
game_id: String(gameId), if (!cardsRes) throw new Error('获取游戏数据失败')
position: String(selectedCardType.value.code || ''),
all_cards: allCards, const allCards = normalizeAllCards(cardsRes.all_cards || cardsRes.data?.all_cards || [])
ts: Date.now() if (!allCards.length) throw new Error('游戏数据为空')
})
} // 5.
writeMatchingGameCacheEntry(aid, iid, {
game_id: String(gameId),
position: String(selectedCardType.value.code || ''),
all_cards: allCards,
ts: Date.now()
})
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '支付成功', icon: 'success' }) uni.showToast({ title: '支付成功', icon: 'success' })
// 5. // 6.
syncResumeGame(aid) syncResumeGame(aid)
const latest = findLatestMatchingGameCacheEntry(aid) const latest = findLatestMatchingGameCacheEntry(aid)
if (latest && latest.entry && latest.entry.game_id) { if (latest && latest.entry && latest.entry.game_id) {
@ -1335,8 +1379,10 @@ onLoad((opts) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transition: all 0.2s;
&:active { &:active {
opacity: 0.6; transform: scale(0.92);
opacity: 0.8;
} }
} }
.action-icon { .action-icon {
@ -1872,4 +1918,219 @@ onLoad((opts) => {
animation: fadeInUp 0.5s ease-out backwards; animation: fadeInUp 0.5s ease-out backwards;
animation-delay: var(--delay, 0s); animation-delay: var(--delay, 0s);
} }
/* ============= 全屏游戏样式 ============= */
.game-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.game-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(16rpx);
animation: fadeIn 0.3s ease-out;
}
.game-fullscreen {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
animation: scaleIn 0.3s ease-out;
}
.game-topbar {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 32rpx;
padding-top: calc(40rpx + env(safe-area-inset-top));
position: relative;
}
.game-topbar-title {
font-size: 36rpx;
font-weight: 800;
color: #fff;
letter-spacing: 2rpx;
}
.game-close-btn {
position: absolute;
right: 32rpx;
top: calc(40rpx + env(safe-area-inset-top));
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
text {
font-size: 48rpx;
color: rgba(255, 255, 255, 0.8);
line-height: 1;
}
&:active {
background: rgba(255, 255, 255, 0.2);
}
}
.game-stats {
display: flex;
justify-content: center;
gap: 48rpx;
padding: 24rpx 32rpx;
margin: 0 32rpx;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.stat-value {
font-size: 44rpx;
font-weight: 900;
color: $accent-gold;
font-family: 'DIN Alternate', sans-serif;
}
.game-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 32rpx;
}
.game-loading,
.game-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.loading-icon,
.error-icon {
font-size: 80rpx;
}
.loading-text,
.error-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.7);
}
.game-board {
width: 100%;
max-width: 700rpx;
padding: 32rpx;
}
.match-grid-fullscreen {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.match-cell-large {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 20rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.empty {
background: rgba(255, 255, 255, 0.03);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.1);
}
&.chosen {
border-color: $brand-primary;
box-shadow: 0 0 24rpx rgba($brand-primary, 0.5);
transform: scale(1.02);
}
&.picked {
border-color: $accent-gold;
box-shadow: 0 0 24rpx rgba($accent-gold, 0.5);
}
&:active {
transform: scale(0.95);
}
}
.match-cell-img-large {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.05);
}
.match-cell-type-large {
position: absolute;
left: 12rpx;
bottom: 12rpx;
max-width: 85%;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.6);
padding: 6rpx 14rpx;
border-radius: 12rpx;
@include text-ellipsis(1);
}
.game-actions {
display: flex;
gap: 24rpx;
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.game-btn {
flex: 1;
height: 96rpx;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
border: none;
transition: all 0.3s;
&:active {
transform: scale(0.95);
}
&[disabled] {
opacity: 0.4;
}
}
</style> </style>

View File

@ -72,6 +72,15 @@
:propCards="propCards" :propCards="propCards"
@confirm="onPaymentConfirm" @confirm="onPaymentConfirm"
/> />
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
</template> </template>
</ActivityPageLayout> </ActivityPageLayout>
</template> </template>
@ -86,6 +95,8 @@ import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue' import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue' import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue' import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import FlipGrid from '@/components/FlipGrid.vue' import FlipGrid from '@/components/FlipGrid.vue'
import PaymentPopup from '@/components/PaymentPopup.vue' import PaymentPopup from '@/components/PaymentPopup.vue'
// Composables // Composables
@ -126,6 +137,8 @@ const {
// ============ ============ // ============ ============
const tabActive = ref('pool') const tabActive = ref('pool')
const rewardsVisible = ref(false) const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showFlip = ref(false) const showFlip = ref(false)
const flipRef = ref(null) const flipRef = ref(null)
const drawLoading = ref(false) const drawLoading = ref(false)
@ -141,15 +154,11 @@ const selectedCard = ref(null)
// ============ ============ // ============ ============
function showRules() { function showRules() {
uni.showModal({ rulesVisible.value = true
title: '活动规则',
content: detail.value.rules || '1. 选择档位进行抽赏\n2. 每次抽赏随机获得奖品\n3. 奖池与概率以页面展示为准',
showCancel: false
})
} }
function goCabinet() { function goCabinet() {
uni.switchTab({ url: '/pages/cabinet/index' }) cabinetVisible.value = true
} }
function closeFlip() { function closeFlip() {

View File

@ -49,20 +49,41 @@
<text class="issue-block-text">{{ orderBlockedReason }}</text> <text class="issue-block-text">{{ orderBlockedReason }}</text>
</view> </view>
<!-- 选号组件 --> <!-- 选号组件 - 隐藏内置操作栏 -->
<view class="selector-body" v-if="activityId && currentIssueId"> <view class="selector-body" v-if="activityId && currentIssueId">
<YifanSelector <YifanSelector
ref="yifanSelectorRef"
:activity-id="activityId" :activity-id="activityId"
:issue-id="currentIssueId" :issue-id="currentIssueId"
:price-per-draw="Number(detail.price_draw || 0) / 100" :price-per-draw="Number(detail.price_draw || 0) / 100"
:disabled="!isOrderAllowed" :disabled="!isOrderAllowed"
:disabled-text="orderBlockedReason" :disabled-text="orderBlockedReason"
:hide-action-bar="true"
@payment-success="onPaymentSuccess" @payment-success="onPaymentSuccess"
@selection-change="onSelectionChange"
/> />
</view> </view>
</view> </view>
</template> </template>
<template #footer>
<!-- 固定底部操作栏 -->
<view class="float-bar">
<view class="float-bar-inner">
<view class="selection-info" v-if="selectedCount > 0">
已选 <text class="highlight">{{ selectedCount }}</text> 个位置
</view>
<view class="selection-info" v-else>
请选择位置
</view>
<view class="action-buttons">
<button v-if="selectedCount === 0" class="action-btn primary" @tap="handleRandomDraw" :disabled="!isOrderAllowed">随机一发</button>
<button v-else class="action-btn primary" @tap="handlePayment" :disabled="!isOrderAllowed">去支付</button>
</view>
</view>
</view>
</template>
<template #modals> <template #modals>
<!-- 翻牌弹窗 --> <!-- 翻牌弹窗 -->
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent> <view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
@ -79,6 +100,18 @@
:title="`${currentIssueTitle} · 奖品与概率`" :title="`${currentIssueTitle} · 奖品与概率`"
:reward-groups="rewardGroups" :reward-groups="rewardGroups"
/> />
<!-- 规则弹窗 -->
<RulesPopup
v-model:visible="rulesVisible"
:content="detail.gameplay_intro"
/>
<!-- 盒柜预览弹窗 -->
<CabinetPreviewPopup
v-model:visible="cabinetVisible"
:activity-id="activityId"
/>
</template> </template>
</ActivityPageLayout> </ActivityPageLayout>
</template> </template>
@ -93,6 +126,8 @@ import ActivityTabs from '@/components/activity/ActivityTabs.vue'
import RewardsPreview from '@/components/activity/RewardsPreview.vue' import RewardsPreview from '@/components/activity/RewardsPreview.vue'
import RewardsPopup from '@/components/activity/RewardsPopup.vue' import RewardsPopup from '@/components/activity/RewardsPopup.vue'
import RecordsList from '@/components/activity/RecordsList.vue' import RecordsList from '@/components/activity/RecordsList.vue'
import RulesPopup from '@/components/activity/RulesPopup.vue'
import CabinetPreviewPopup from '@/components/activity/CabinetPreviewPopup.vue'
import FlipGrid from '@/components/FlipGrid.vue' import FlipGrid from '@/components/FlipGrid.vue'
import YifanSelector from '@/components/YifanSelector.vue' import YifanSelector from '@/components/YifanSelector.vue'
// Composables // Composables
@ -133,8 +168,31 @@ const {
// ============ ============ // ============ ============
const tabActive = ref('pool') const tabActive = ref('pool')
const rewardsVisible = ref(false) const rewardsVisible = ref(false)
const rulesVisible = ref(false)
const cabinetVisible = ref(false)
const showFlip = ref(false) const showFlip = ref(false)
const flipRef = ref(null) const flipRef = ref(null)
const yifanSelectorRef = ref(null)
const selectedCount = ref(0) //
//
function onSelectionChange(items) {
selectedCount.value = Array.isArray(items) ? items.length : 0
}
//
function handleRandomDraw() {
if (yifanSelectorRef.value && yifanSelectorRef.value.handleRandomOne) {
yifanSelectorRef.value.handleRandomOne()
}
}
//
function handlePayment() {
if (yifanSelectorRef.value && yifanSelectorRef.value.handleBuy) {
yifanSelectorRef.value.handleBuy()
}
}
// ============ ============ // ============ ============
const nowMs = ref(Date.now()) const nowMs = ref(Date.now())
@ -179,15 +237,11 @@ const orderBlockedReason = computed(() => {
// ============ ============ // ============ ============
function showRules() { function showRules() {
uni.showModal({ rulesVisible.value = true
title: '活动规则',
content: detail.value.rules || '1. 选择号码进行抽选\n2. 每个号码对应一个奖品\n3. 已售号码不可再选\n4.未满足开赏条件,将自动为所有参与用户退款,款项将原路返回',
showCancel: false
})
} }
function goCabinet() { function goCabinet() {
uni.switchTab({ url: '/pages/cabinet/index' }) cabinetVisible.value = true
} }
function closeFlip() { function closeFlip() {
@ -426,4 +480,77 @@ watch(currentIssueId, (newId) => {
border: none; border: none;
} }
} }
/* ============= 底部固定操作栏 ============= */
.float-bar {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(40rpx + env(safe-area-inset-bottom));
z-index: 100;
animation: slideUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) backwards;
}
.float-bar-inner {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(30rpx);
padding: 24rpx 40rpx;
border-radius: 999rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.6);
}
.selection-info {
font-size: 28rpx;
color: $text-main;
display: flex;
align-items: baseline;
font-weight: 800;
}
.highlight {
color: $brand-primary;
font-weight: 900;
font-size: 40rpx;
margin: 0 8rpx;
font-family: 'DIN Alternate', sans-serif;
}
.action-buttons {
display: flex;
gap: 20rpx;
}
.action-btn {
height: 88rpx;
line-height: 88rpx;
padding: 0 56rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 900;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: none;
&::after {
border: none;
}
&:active {
transform: scale(0.92);
}
&.primary {
background: $gradient-brand !important;
color: #FFFFFF !important;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.35);
}
}
</style> </style>

View File

@ -1,7 +1,9 @@
<template> <template>
<view class="wrap"> <view class="wrap">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 顶部 Tab --> <!-- 顶部 Tab -->
<view class="tabs"> <view class="tabs glass-card">
<view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)"> <view class="tab-item" :class="{ active: currentTab === 0 }" @tap="switchTab(0)">
<text class="tab-text">待处理</text> <text class="tab-text">待处理</text>
<text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text> <text class="tab-count" v-if="aggregatedList.length > 0">({{ aggregatedList.length }})</text>
@ -79,7 +81,7 @@
<view class="shipment-status" :class="getStatusClass(item.status)"> <view class="shipment-status" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }} {{ getStatusText(item.status) }}
</view> </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" @tap="onCancelShipping(item)">撤销发货</text>
</view> </view>
</view> </view>
@ -301,7 +303,8 @@ function getStatusClass(status) {
1: 'status-pending', // 1: 'status-pending', //
2: 'status-shipped', // 2: 'status-shipped', //
3: 'status-delivered', // 3: 'status-delivered', //
4: 'status-cancelled' // 4: 'status-abnormal', //
5: 'status-cancelled' //
} }
return statusMap[status] || 'status-pending' return statusMap[status] || 'status-pending'
} }
@ -311,7 +314,8 @@ function getStatusText(status) {
1: '待发货', 1: '待发货',
2: '运输中', 2: '运输中',
3: '已签收', 3: '已签收',
4: '已取消' 4: '异常',
5: '已取消'
} }
return statusMap[status] || '待发货' return statusMap[status] || '待发货'
} }
@ -590,6 +594,7 @@ async function fetchProductPrices() {
} }
function toggleSelect(item) { function toggleSelect(item) {
uni.vibrateShort({ type: 'light' })
item.selected = !item.selected item.selected = !item.selected
if (item.selected) { if (item.selected) {
// //
@ -604,6 +609,7 @@ function toggleSelect(item) {
} }
function toggleSelectAll() { function toggleSelectAll() {
uni.vibrateShort({ type: 'light' })
const newState = !isAllSelected.value const newState = !isAllSelected.value
aggregatedList.value.forEach(item => { aggregatedList.value.forEach(item => {
item.selected = newState item.selected = newState
@ -628,6 +634,7 @@ function changeCount(item, delta) {
} }
async function onRedeem() { async function onRedeem() {
uni.vibrateShort({ type: 'medium' })
const user_id = uni.getStorageSync('user_id') const user_id = uni.getStorageSync('user_id')
if (!user_id) return if (!user_id) return
@ -675,6 +682,7 @@ async function onRedeem() {
} }
async function onShip() { async function onShip() {
uni.vibrateShort({ type: 'medium' })
const user_id = uni.getStorageSync('user_id') const user_id = uni.getStorageSync('user_id')
if (!user_id) return if (!user_id) return
@ -726,15 +734,15 @@ function onCancelShipping(shipment) {
const batchNo = shipment && shipment.batch_no const batchNo = shipment && shipment.batch_no
if (!user_id || !batchNo) return if (!user_id || !batchNo) return
uni.showModal({ uni.showModal({
title: '取消发货', title: '撤销发货',
content: `确认取消发货单 ${batchNo} 吗?`, content: `确认不再发货,并撤销发货单 ${batchNo} 吗?`,
confirmText: '确认取消', confirmText: '确认撤销',
success: async (res) => { success: async (res) => {
if (!res.confirm) return if (!res.confirm) return
uni.showLoading({ title: '处理中...' }) uni.showLoading({ title: '处理中...' })
try { try {
await cancelShipping(user_id, batchNo) await cancelShipping(user_id, batchNo)
uni.showToast({ title: '已取消发货', icon: 'success' }) uni.showToast({ title: '已撤销发货', icon: 'success' })
page.value = 1 page.value = 1
hasMore.value = true hasMore.value = true
shippedList.value = [] shippedList.value = []
@ -762,25 +770,28 @@ function onCancelShipping(shipment) {
padding-bottom: calc(180rpx + env(safe-area-inset-bottom)); padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
overflow: hidden;
} }
/* 顶部 Tab */ /* 顶部 Tab */
.tabs { .tabs {
@extend .glass-card;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 88rpx; height: 88rpx;
background: rgba($bg-card, 0.9);
backdrop-filter: blur(20rpx);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
box-shadow: $shadow-sm;
padding: 0; padding: 0;
margin: 0; margin: 0;
border-radius: 0; border-radius: 0;
border-top: none;
border-left: none;
border-right: none;
} }
.tab-item { .tab-item {
@ -1233,10 +1244,6 @@ function onCancelShipping(shipment) {
from { opacity: 0; transform: translateY(20rpx); } from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.bottom-spacer { .bottom-spacer {
height: 120rpx; height: 120rpx;

729
pages/coupons/index.vue Normal file
View File

@ -0,0 +1,729 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">我的优惠券</view>
<view class="page-subtitle">My Coupons</view>
</view>
<!-- Tab栏 - 毛玻璃风格 -->
<view class="tab-bar glass-card">
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text class="tab-text">未使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 2 }" @click="switchTab(2)">
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 2"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 3 }" @click="switchTab(3)">
<text class="tab-text">已过期</text>
<view class="tab-indicator" v-if="currentTab === 3"></view>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🎟</text>
<text class="empty-text">{{ getEmptyText() }}</text>
</view>
<!-- 优惠券列表 -->
<view v-else class="coupon-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="coupon-ticket"
:class="getCouponClass()"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧金额区域 -->
<view class="coupon-left">
<view class="coupon-value">
<text class="coupon-symbol">¥</text>
<text class="coupon-amount">{{ formatValue(item.remaining ?? item.amount ?? 0) }}</text>
</view>
<text class="coupon-label">{{ currentTab === 1 ? '可用' : (currentTab === 2 ? '已用' : '过期') }}</text>
</view>
<!-- 中间分割线 -->
<view class="coupon-divider">
<view class="divider-notch top"></view>
<view class="divider-dash"></view>
<view class="divider-notch bottom"></view>
</view>
<!-- 右侧信息区域 -->
<view class="coupon-right">
<view class="coupon-header">
<text class="coupon-name">{{ item.name || '优惠券' }}</text>
<view class="coupon-original" v-if="item.amount && item.remaining !== undefined && item.remaining !== item.amount">
<text>原值 ¥{{ formatValue(item.amount) }}</text>
</view>
</view>
<text class="coupon-rules">{{ item.rules || '全场通用' }}</text>
<!-- 使用进度条 -->
<view class="coupon-progress" v-if="item.amount && item.remaining !== undefined && item.remaining < item.amount">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: getUsedPercent(item) + '%' }"></view>
</view>
<text class="progress-text">已用 {{ formatValue(item.amount - item.remaining) }} ({{ getUsedPercent(item) }}%)</text>
</view>
<view class="coupon-footer">
<view class="footer-left">
<text class="coupon-expire">{{ formatExpiry(item) }}</text>
<text class="coupon-used-time" v-if="currentTab === 2 && item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
</view>
</view>
<!-- 优化后的按钮位置 -->
<view class="coupon-action-wrapper" v-if="currentTab === 1">
<view class="use-btn" @click.stop="onUseCoupon(item)">
<text class="btn-text">去使用</text>
<view class="btn-shine"></view>
</view>
</view>
<view class="coupon-status" v-else>
<text class="status-tag" :class="currentTab === 2 ? 'used' : 'expired'">{{ currentTab === 2 ? '已使用' : '已过期' }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import { getUserCoupons } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const currentTab = ref(1)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
// ()
function formatValue(val) {
return (Number(val) / 100).toFixed(0)
}
//
function formatExpiry(item) {
if (!item.end_time) return '长期有效'
const d = new Date(item.end_time)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `有效期至 ${y}-${m}-${day}`
}
//
function formatDateTime(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
// 使
function getUsedPercent(item) {
if (!item.amount || !item.remaining) return 0
const used = item.amount - item.remaining
return Math.floor((used / item.amount) * 100)
}
//
function getEmptyText() {
if (currentTab.value === 1) return '暂无可用优惠券'
if (currentTab.value === 2) return '暂无使用记录'
return '暂无过期优惠券'
}
//
function getCouponClass() {
if (currentTab.value === 2) return 'coupon-used'
if (currentTab.value === 3) return 'coupon-expired'
return ''
}
// Tab
function switchTab(tab) {
if (currentTab.value === tab) return
uni.vibrateShort({ type: 'light' })
currentTab.value = tab
list.value = []
page.value = 1
hasMore.value = true
fetchData()
}
//
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
//
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
//
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
// status: 0=unused, 1=used, 2=expired
const statusMap = { 1: 0, 2: 1, 3: 2 }
const res = await getUserCoupons(userId, statusMap[currentTab.value], page.value, pageSize)
const items = res.list || res.data || []
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取优惠券失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
// 使
function onUseCoupon(item) {
uni.vibrateShort({ type: 'medium' })
//
uni.switchTab({
url: '/pages/index/index'
})
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与个人中心统一) */
.bg-decoration {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
.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;
}
/* Tab栏 */
.tab-bar {
@extend .glass-card;
display: flex;
margin: 0 $spacing-lg;
padding: 8rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
transition: all 0.3s;
}
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
}
.tab-item.active .tab-text {
color: $text-main;
font-weight: 700;
}
.tab-indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 280rpx);
padding: $spacing-lg;
position: relative;
z-index: 1;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 优惠券列表 */
.coupon-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 优惠券卡片 */
.coupon-ticket {
background: #fff;
border-radius: 16rpx;
display: flex;
overflow: hidden;
box-shadow: $shadow-sm;
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.coupon-left {
width: 180rpx;
background: linear-gradient(135deg, #FFF5E6, #fff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
position: relative;
}
.coupon-value {
color: $brand-primary;
font-weight: 900;
}
.coupon-symbol {
font-size: 24rpx;
}
.coupon-amount {
font-size: 56rpx;
line-height: 1;
}
.coupon-label {
font-size: 20rpx;
color: $brand-primary;
margin-top: 8rpx;
border: 1px solid $brand-primary;
padding: 2rpx 8rpx;
border-radius: 6rpx;
}
/* 分割线 */
.coupon-divider {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top {
top: -12rpx;
}
.divider-notch.bottom {
bottom: -12rpx;
}
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.coupon-right {
flex: 1;
padding: 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
}
.coupon-header {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.coupon-name {
font-size: $font-md;
font-weight: 700;
color: $text-main;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coupon-original {
font-size: 20rpx;
color: $text-tertiary;
text-decoration: line-through;
margin-left: 8rpx;
}
.coupon-rules {
font-size: $font-xs;
color: $text-sub;
margin-bottom: 16rpx;
}
/* 进度条 */
.coupon-progress {
margin-bottom: 12rpx;
}
.progress-bar {
height: 6rpx;
background: $bg-secondary;
border-radius: 100rpx;
overflow: hidden;
margin-bottom: 4rpx;
}
.progress-fill {
height: 100%;
background: $brand-primary;
border-radius: 100rpx;
}
.progress-text {
font-size: 18rpx;
color: $text-tertiary;
}
.coupon-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
}
.footer-left {
display: flex;
flex-direction: column;
}
.coupon-expire {
font-size: 20rpx;
color: $text-tertiary;
}
.coupon-used-time {
font-size: 18rpx;
color: $text-tertiary;
margin-top: 4rpx;
}
/* 优化后的按钮样式 */
.coupon-action-wrapper {
position: absolute;
right: 24rpx;
bottom: 24rpx;
z-index: 10;
}
.use-btn {
background: linear-gradient(135deg, #FF8D3F, #FF5C00);
padding: 12rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba(255, 92, 0, 0.3);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&:active {
transform: scale(0.92);
box-shadow: 0 2rpx 10rpx rgba(255, 92, 0, 0.2);
}
}
.btn-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 2;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 150%; }
}
.status-tag {
font-size: 22rpx;
color: $text-tertiary;
background: #F5F5F5;
padding: 6rpx 16rpx;
border-radius: 8rpx;
margin-left: auto;
}
/* 过期/已使用状态 */
.coupon-used .coupon-left,
.coupon-expired .coupon-left {
background: #f9f9f9;
}
.coupon-used .coupon-value,
.coupon-expired .coupon-value,
.coupon-used .coupon-label,
.coupon-expired .coupon-label {
color: $text-tertiary;
border-color: $text-tertiary;
}
.coupon-used .coupon-name,
.coupon-expired .coupon-name {
color: $text-sub;
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>

View File

@ -1,18 +1,11 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 品牌级背景装饰系统 --> <!-- 品牌级背景装饰系统 - 统一漂浮光球 -->
<view class="premium-bg"> <view class="bg-decoration"></view>
<view class="bg-shape circle-1"></view>
<view class="bg-shape circle-2"></view>
<view class="bg-shape mesh-gradient"></view>
</view>
<!-- 顶部导航栏 (搜索) --> <!-- 顶部导航栏 (搜索) -->
<view class="nav-header"> <view class="nav-header">
<view class="brand-logo"> <!-- 品牌标识已按需移除 -->
<text class="brand-text">柯大鸭潮玩</text>
<view class="brand-star"></view>
</view>
</view> </view>
<!-- 滚动区域 --> <!-- 滚动区域 -->
@ -333,7 +326,7 @@ export default {
onShareAppMessage() { onShareAppMessage() {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || '' const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return { return {
title: '柯大鸭潮玩 - 开箱惊喜等你来', title: '柯大鸭 - 开箱惊喜等你来',
path: `/pages/index/index?invite_code=${inviteCode}`, path: `/pages/index/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png' imageUrl: '/static/logo.png'
} }
@ -342,7 +335,7 @@ export default {
onShareTimeline() { onShareTimeline() {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || '' const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return { return {
title: '柯大鸭潮玩 - 开箱惊喜等你来', title: '柯大鸭 - 开箱惊喜等你来',
query: `invite_code=${inviteCode}`, query: `invite_code=${inviteCode}`,
imageUrl: '/static/logo.png' imageUrl: '/static/logo.png'
} }
@ -352,88 +345,31 @@ export default {
<style lang="scss"> <style lang="scss">
/* ============================================ /* ============================================
柯大鸭潮玩 - 首页样式 (V6.0 Pro Refined) 柯大鸭 - 首页样式 (V6.0 Pro Refined)
============================================ */ ============================================ */
.page { .page {
padding: 0; padding: 0;
background-color: #F8F9FB; background-color: $bg-page;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
overflow-x: hidden;
}
/* ========== 品牌级背景系统 ========== */
.premium-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden; overflow: hidden;
} }
.bg-shape {
position: absolute;
filter: blur(100rpx);
opacity: 0.6;
}
.circle-1 {
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba(255, 107, 0, 0.15) 0%, transparent 70%);
top: -200rpx;
right: -100rpx;
animation: float 10s ease-in-out infinite alternate;
}
.circle-2 {
width: 500rpx;
height: 500rpx;
background: radial-gradient(circle, rgba(255, 193, 7, 0.1) 0%, transparent 70%);
bottom: 200rpx;
left: -100rpx;
animation: float 12s ease-in-out infinite alternate-reverse;
}
.mesh-gradient {
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 107, 0, 0.03) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 193, 7, 0.03) 100%);
}
/* ========== 顶部导航栏 ========== */ /* ========== 顶部导航栏 ========== */
.nav-header { .nav-header {
display: flex; height: env(safe-area-inset-top);
align-items: center;
padding: $spacing-md $spacing-lg;
padding-top: calc($spacing-md + env(safe-area-inset-top));
z-index: 10; z-index: 10;
position: sticky; position: sticky;
top: 0; top: 0;
background: rgba($bg-page, 0.8);
backdrop-filter: blur(10rpx);
} }
.brand-text {
font-size: 44rpx;
font-weight: 900;
color: $text-main;
font-style: italic;
letter-spacing: -2rpx;
background: linear-gradient(135deg, #1A1A1A 0%, #444 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.brand-star { /* ========== 滚动主内容区 ========== */
font-size: 28rpx;
margin-left: 6rpx;
}
/* ========== 滚动主内容区 ========== */ /* ========== 滚动主内容区 ========== */
@ -441,19 +377,17 @@ export default {
flex: 1; flex: 1;
position: relative; position: relative;
z-index: 1; z-index: 1;
padding-top: $spacing-sm;
} }
/* Banner Container (Modern Floating) */ /* Banner Container (Modern Floating) */
.banner-container { .banner-container {
padding: 0 $spacing-lg $spacing-xl; padding: $spacing-sm $spacing-lg $spacing-xl;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.banner-swiper { .banner-swiper {
height: 360rpx; height: 360rpx;
overflow: visible; /* 让阴影不被切断 */
} }
.banner-card { .banner-card {
@ -464,7 +398,7 @@ export default {
position: relative; position: relative;
transform: scale(0.96); transform: scale(0.96);
transition: all 0.5s $ease-out; transition: all 0.5s $ease-out;
box-shadow: 0 16rpx 48rpx rgba(255, 107, 0, 0.1); box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.08);
} }
.banner-card.active { .banner-card.active {
@ -541,14 +475,15 @@ export default {
/* Notice Bar V2 (Minimalist) */ /* Notice Bar V2 (Minimalist) */
.notice-bar-v2 { .notice-bar-v2 {
margin: 0 $spacing-lg $spacing-xl; margin: 0 $spacing-lg $spacing-xl;
background: #FFFFFF; background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10rpx);
border-radius: 32rpx; border-radius: 32rpx;
padding: 24rpx 32rpx; padding: 24rpx 32rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20rpx; gap: 20rpx;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
border: 1rpx solid rgba(0,0,0,0.02); border: 1px solid rgba(255, 255, 255, 0.6);
} }
.notice-icon { font-size: 32rpx; } .notice-icon { font-size: 32rpx; }
@ -587,6 +522,7 @@ export default {
font-style: italic; font-style: italic;
display: flex; display: flex;
align-items: center; align-items: center;
text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
} }
.section-title::before { .section-title::before {
@ -619,6 +555,7 @@ export default {
padding: 22rpx; padding: 22rpx;
box-shadow: $shadow-card; box-shadow: $shadow-card;
transition: transform 0.2s; transition: transform 0.2s;
border: 1px solid rgba(255, 255, 255, 0.2);
} }
.game-card-large:active { .game-card-large:active {
@ -757,17 +694,20 @@ export default {
} }
.activity-item { .activity-item {
background: #FFFFFF; background: rgba(255, 255, 255, 0.7);
border-radius: 32rpx; backdrop-filter: blur(10rpx);
border-radius: $radius-xl;
overflow: hidden; overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.03); box-shadow: $shadow-sm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: all 0.3s ease; transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.6);
} }
.activity-item:active { .activity-item:active {
transform: translateY(4rpx); transform: translateY(4rpx);
box-shadow: none;
} }
.activity-thumb-box { .activity-thumb-box {

403
pages/invites/index.vue Normal file
View File

@ -0,0 +1,403 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">邀请记录</view>
<view class="page-subtitle">Invitations</view>
</view>
<!-- 统计卡片 - 毛玻璃风格 -->
<view class="stats-card glass-card">
<view class="stat-item">
<text class="stat-num">{{ list.length }}</text>
<text class="stat-label">邀请人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num">{{ getRewardsTotal() }}</text>
<text class="stat-label">累计奖励</text>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.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="invite-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="invite-item"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<image class="invite-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill"></image>
<view class="invite-info">
<text class="invite-name">{{ item.nickname || '用户' + item.id }}</text>
<text class="invite-time">{{ formatDate(item.created_at) }}</text>
</view>
<view class="invite-status">
<text class="status-text">已邀请</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loading && list.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && list.length > 0" class="no-more">- 到底啦 -</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getUserInvites } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function formatDate(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
//
function getRewardsTotal() {
// ×
const rewardPerInvite = 10 //
return list.value.length * rewardPerInvite
}
//
async function onRefresh() {
isRefreshing.value = true
page.value = 1
hasMore.value = true
await fetchData(false)
isRefreshing.value = false
}
//
async function loadMore() {
if (loading.value || !hasMore.value) return
await fetchData(true)
}
//
async function fetchData(append = false) {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
const res = await getUserInvites(userId, page.value, pageSize)
const items = res.list || res.data || []
if (append) {
list.value = [...list.value, ...items]
} else {
list.value = items
}
if (items.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
} catch (e) {
console.error('获取邀请记录失败:', e)
hasMore.value = false
} finally {
loading.value = false
}
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.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;
}
/* 统计卡片 */
.stats-card {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-lg;
padding: 40rpx;
display: flex;
justify-content: center;
align-items: center;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 56rpx;
font-weight: 900;
color: $brand-primary;
font-family: 'DIN Alternate', sans-serif;
line-height: 1;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 24rpx;
color: $text-sub;
}
.stat-divider {
width: 1px;
height: 60rpx;
background: $border-color-light;
margin: 0 40rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 400rpx);
padding: 0 $spacing-lg $spacing-lg;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 邀请列表 */
.invite-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.invite-item {
background: #fff;
border-radius: $radius-lg;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: $shadow-sm;
animation: fadeInUp 0.5s ease-out backwards;
&:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.8);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.invite-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: $bg-secondary;
margin-right: 24rpx;
flex-shrink: 0;
}
.invite-info {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.invite-name {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invite-time {
font-size: 24rpx;
color: $text-tertiary;
}
.invite-status {
flex-shrink: 0;
}
.status-text {
font-size: 24rpx;
color: $uni-color-success;
background: rgba($uni-color-success, 0.1);
padding: 6rpx 16rpx;
border-radius: 100rpx;
}
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>

623
pages/item-cards/index.vue Normal file
View File

@ -0,0 +1,623 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">我的道具卡</view>
<view class="page-subtitle">My Item Cards</view>
</view>
<!-- Tab栏 - 毛玻璃风格 -->
<view class="tab-bar glass-card">
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
<text class="tab-text">未使用</text>
<view class="tab-indicator" v-if="currentTab === 0"></view>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text class="tab-text">已使用</text>
<view class="tab-indicator" v-if="currentTab === 1"></view>
</view>
</view>
<!-- 内容区 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="list.length === 0" class="empty-state">
<text class="empty-icon">🃏</text>
<text class="empty-text">{{ currentTab === 0 ? '暂无可用道具卡' : '暂无使用记录' }}</text>
</view>
<!-- 道具卡列表 -->
<view v-else class="item-list">
<view
v-for="(item, index) in list"
:key="item.id || index"
class="item-ticket"
:class="{ 'used': currentTab === 1 }"
:style="{ animationDelay: `${index * 0.05}s` }"
>
<!-- 左侧图标区域 -->
<view class="ticket-left">
<view class="card-icon-wrap">
<text class="card-icon">{{ getCardIcon(item.type || item.name) }}</text>
</view>
<view class="card-count-badge" v-if="currentTab === 0">
<text class="count-num">×{{ item.remaining ?? item.count ?? 1 }}</text>
</view>
</view>
<!-- 中间分割线 -->
<view class="ticket-divider">
<view class="divider-notch top"></view>
<view class="divider-dash"></view>
<view class="divider-notch bottom"></view>
</view>
<!-- 右侧信息区域 -->
<view class="ticket-right">
<view class="card-info">
<text class="card-name">{{ item.name || item.title || '道具卡' }}</text>
<text class="card-desc">{{ item.description || item.rules || '可在抽奖时使用' }}</text>
<view class="usage-info" v-if="currentTab === 1">
<text class="card-use-time" v-if="item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
<view class="usage-detail" v-if="item.used_activity_name">
<text class="detail-label">使用于</text>
<text class="detail-val">{{ item.used_activity_name }}</text>
<text class="detail-val" v-if="item.used_issue_number"> - 期号 {{ item.used_issue_number }}</text>
</view>
<view class="usage-detail" v-if="item.used_reward_name">
<text class="detail-label">效果</text>
<text class="detail-val highlight">{{ item.used_reward_name }}</text>
</view>
</view>
</view>
<!-- 优化后的按钮位置 -->
<view class="ticket-action-wrapper" v-if="currentTab === 0">
<view class="use-btn" @click.stop="onUseCard(item)">
<text class="btn-text">去使用</text>
<view class="btn-shine"></view>
</view>
</view>
<view class="card-used-badge" v-else>
<text class="used-text">已使用</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getItemCards } from '../../api/appUser'
const list = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const currentTab = ref(0)
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function formatDateTime(t) {
if (!t) return ''
const d = new Date(t)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
//
function getCardIcon(type) {
const t = String(type || '').toLowerCase()
if (t.includes('透视')) return '👁️'
if (t.includes('提示')) return '💡'
if (t.includes('重置')) return '🔄'
if (t.includes('翻倍')) return '✨'
if (t.includes('保护')) return '🛡️'
return '🃏'
}
// Tab
function switchTab(tab) {
if (currentTab.value === tab) return
uni.vibrateShort({ type: 'light' })
currentTab.value = tab
list.value = []
fetchData()
}
//
async function onRefresh() {
isRefreshing.value = true
await fetchData()
isRefreshing.value = false
}
//
async function fetchData() {
if (!checkAuth()) return
if (loading.value) return
loading.value = true
try {
const userId = getUserId()
// status: 1=unused, 2=used
const status = currentTab.value === 0 ? 1 : 2
const res = await getItemCards(userId, status)
let items = Array.isArray(res) ? res : (res.list || res.data || [])
// count
items = items.map(item => ({
...item,
count: item.count ?? item.remaining ?? 1
}))
// 使0
if (currentTab.value === 0) {
items = items.filter(i => i.count > 0)
}
list.value = items
} catch (e) {
console.error('获取道具卡失败:', e)
list.value = []
} finally {
loading.value = false
}
}
// 使
function onUseCard(item) {
uni.vibrateShort({ type: 'medium' })
//
uni.switchTab({
url: '/pages/index/index'
})
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.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;
}
/* Tab栏 */
.tab-bar {
@extend .glass-card;
display: flex;
margin: 0 $spacing-lg;
padding: 8rpx;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
position: relative;
transition: all 0.3s;
}
.tab-text {
font-size: 28rpx;
color: $text-sub;
font-weight: 500;
}
.tab-item.active .tab-text {
color: $text-main;
font-weight: 700;
}
.tab-indicator {
position: absolute;
bottom: 4rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 280rpx);
padding: $spacing-lg;
position: relative;
z-index: 1;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
/* 道具卡列表 */
.item-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 票券式卡片 */
.item-ticket {
background: #fff;
border-radius: 16rpx;
display: flex;
overflow: hidden;
box-shadow: $shadow-sm;
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ticket-left {
width: 180rpx;
background: linear-gradient(135deg, #E6F7FF, #fff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx;
position: relative;
}
.card-icon-wrap {
width: 90rpx;
height: 90rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 150, 250, 0.1);
}
.card-icon {
font-size: 48rpx;
}
.card-count-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
background: rgba(0, 150, 250, 0.1);
padding: 2rpx 10rpx;
border-radius: 100rpx;
}
.count-num {
font-size: 20rpx;
font-weight: 700;
color: #0096FA;
}
/* 分割线 */
.ticket-divider {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-page;
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top {
top: -12rpx;
}
.divider-notch.bottom {
bottom: -12rpx;
}
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.ticket-right {
flex: 1;
padding: 24rpx;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
position: relative;
}
.card-info {
display: flex;
flex-direction: column;
padding-right: 100rpx;
}
.card-name {
font-size: $font-md;
font-weight: 700;
margin-bottom: 8rpx;
color: $text-main;
}
.card-desc {
font-size: $font-xs;
color: $text-sub;
line-height: 1.4;
}
.card-desc {
font-size: $font-xs;
color: $text-sub;
line-height: 1.4;
}
.usage-info {
margin-top: 16rpx;
padding-top: 12rpx;
border-top: 1rpx dashed #eee;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-use-time {
font-size: 18rpx;
color: $text-tertiary;
}
.usage-detail {
font-size: 20rpx;
color: $text-sub;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.detail-label {
color: $text-tertiary;
}
.detail-val {
font-weight: 500;
margin-left: 4rpx;
&.highlight {
color: $brand-primary;
font-weight: 700;
}
}
/* 按钮样式 */
.ticket-action-wrapper {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
.use-btn {
background: linear-gradient(135deg, #4facfe, #00f2fe);
padding: 12rpx 28rpx;
border-radius: 40rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 150, 250, 0.2);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
&:active {
transform: scale(0.92);
box-shadow: 0 2rpx 10rpx rgba(0, 150, 250, 0.15);
}
}
.btn-text {
color: #fff;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 2rpx;
position: relative;
z-index: 2;
}
.btn-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
20%, 100% { left: 150%; }
}
.card-used-badge {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
background: #F5F5F5;
padding: 6rpx 16rpx;
border-radius: 8rpx;
}
.used-text {
font-size: 22rpx;
color: $text-tertiary;
}
/* 已使用状态 */
.item-ticket.used {
.ticket-left {
background: #f9f9f9;
}
.card-icon-wrap {
filter: grayscale(1);
opacity: 0.5;
}
.card-name {
color: $text-sub;
}
.card-desc {
color: $text-tertiary;
}
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -11,7 +11,6 @@
<view class="logo-box"> <view class="logo-box">
<image class="logo" src="/static/logo.png" mode="widthFix"></image> <image class="logo" src="/static/logo.png" mode="widthFix"></image>
</view> </view>
<view class="app-name">柯大鸭潮玩</view>
<view class="welcome-text">开启欧气之旅 </view> <view class="welcome-text">开启欧气之旅 </view>
</view> </view>

View File

@ -11,35 +11,36 @@
<view class="user-meta"> <view class="user-meta">
<view class="name-row"> <view class="name-row">
<text class="nickname">{{ nickname || '未登录' }}</text> <text class="nickname">{{ nickname || '未登录' }}</text>
<view class="level-badge" v-if="nickname"> <view class="level-badge" v-if="title">
<image class="level-icon" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNiAzSDE4TDIxIDlMOSAyMSAzIDlMNiAzWiIgLz48L3N2Zz4=" mode="aspectFit"></image> <image class="level-icon" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNiAzSDE4TDIxIDlMOSAyMSAzIDlMNiAzWiIgLz48L3N2Zz4=" mode="aspectFit"></image>
<text class="level-text">Lv1 青铜</text> <text class="level-text">Lv1 {{ title }}</text>
</view> </view>
</view> </view>
<view class="progress-container" v-if="nickname"> <view class="userid" v-if="userId">ID: {{ userId }}</view>
<!-- <view class="progress-container" v-if="nickname">
<view class="progress-bar"> <view class="progress-bar">
<view class="progress-fill" style="width: 20%;"></view> <view class="progress-fill" style="width: 20%;"></view>
</view> </view>
<text class="progress-text">100/5000 升级Lv2</text> <text class="progress-text">100/5000 升级Lv2</text>
</view> </view> -->
<view class="userid" v-else>ID: {{ userId || '-' }}</view> <!-- <view class="userid" v-else>ID: {{ userId || '-' }}</view> -->
</view> </view>
<view class="join-btn" @click="handleJoin" v-if="!nickname">立即登录</view> <view class="join-btn" @click="handleJoin" v-if="!nickname">立即登录</view>
</view> </view>
<!-- 数据统计栏 --> <!-- 数据统计栏 -->
<view class="stats-row"> <view class="stats-row">
<view class="stat-item" @click="showPointsPopup"> <view class="stat-item" @click="toPointsPage">
<text class="stat-num">{{ pointsBalance || 0 }}</text> <text class="stat-num">{{ pointsBalance || 0 }}</text>
<text class="stat-label">积分</text> <text class="stat-label">积分</text>
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-item" @click="showCouponsPopup"> <view class="stat-item" @click="toCouponsPage">
<text class="stat-num">{{ stats.coupon_count || 0 }}</text> <text class="stat-num">{{ stats.coupon_count || 0 }}</text>
<text class="stat-label">优惠券</text> <text class="stat-label">优惠券</text>
</view> </view>
<view class="stat-divider"></view> <view class="stat-divider"></view>
<view class="stat-item" @click="showItemCardsPopup"> <view class="stat-item" @click="toItemCardsPage">
<text class="stat-num">{{ stats.item_card_count || 0 }}</text> <text class="stat-num">{{ stats.item_card_count || 0 }}</text>
<text class="stat-label">道具卡</text> <text class="stat-label">道具卡</text>
</view> </view>
@ -130,25 +131,25 @@
<text class="section-title">常用功能</text> <text class="section-title">常用功能</text>
</view> </view>
<view class="grid-menu"> <view class="grid-menu">
<view class="menu-item" @click="showCouponsPopup"> <view class="menu-item" @click="toCouponsPage">
<view class="menu-icon-box"> <view class="menu-icon-box">
<image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMSA2SDNhMiAyIDAgMCAwLTIgMnY4YTIgMiAwIDAgMCAyIDJoMThhMiAyIDAgMCAwIDItMnYtOGEyIDIgMCAwIDAtMi0yWiIgLz48cGF0aCBkPSJNNiAxMnYtMiIgLz48cGF0aCBkPSJNNiAxNnYtMiIgLz48cGF0aCBkPSJNMTYgNnYxMiIgLz48L3N2Zz4=" mode="aspectFit"></image> <image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMSA2SDNhMiAyIDAgMCAwLTIgMnY4YTIgMiAwIDAgMCAyIDJoMThhMiAyIDAgMCAwIDItMnYtOGEyIDIgMCAwIDAtMi0yWiIgLz48cGF0aCBkPSJNNiAxMnYtMiIgLz48cGF0aCBkPSJNNiAxNnYtMiIgLz48cGF0aCBkPSJNMTYgNnYxMiIgLz48L3N2Zz4=" mode="aspectFit"></image>
</view> </view>
<text class="menu-label">优惠券</text> <text class="menu-label">优惠券</text>
</view> </view>
<view class="menu-item" @click="showItemCardsPopup"> <view class="menu-item" @click="toItemCardsPage">
<view class="menu-icon-box"> <view class="menu-icon-box">
<image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iNCIgLz48cGF0aCBkPSJNMjEgMTVsLTUuNDMtMy4yM2EyIDIgMCAwIDAtMS44NCAwbC0yLjMxIDEuMzciIC8+PC9zdmc+" mode="aspectFit"></image> <image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iNCIgLz48cGF0aCBkPSJNMjEgMTVsLTUuNDMtMy4yM2EyIDIgMCAwIDAtMS44NCAwbC0yLjMxIDEuMzciIC8+PC9zdmc+" mode="aspectFit"></image>
</view> </view>
<text class="menu-label">道具卡</text> <text class="menu-label">道具卡</text>
</view> </view>
<view class="menu-item" @click="showTasksPopup"> <view class="menu-item" @click="toTasksPage">
<view class="menu-icon-box"> <view class="menu-icon-box">
<image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDExbDMgM0wyMiA0Ii8+PHBhdGggZD0iTTIxIDEydjdhMiAyIDAgMCAxLTIgMkg1YTIgMiAwIDAgMS0yLTJWNWEyIDIgMCAwIDEgMi0yaDExIi8+PC9zdmc+" mode="aspectFit"></image> <image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDExbDMgM0wyMiA0Ii8+PHBhdGggZD0iTTIxIDEydjdhMiAyIDAgMCAxLTIgMkg1YTIgMiAwIDAgMS0yLTJWNWEyIDIgMCAwIDEgMi0yaDExIi8+PC9zdmc+" mode="aspectFit"></image>
</view> </view>
<text class="menu-label">任务中心</text> <text class="menu-label">任务中心</text>
</view> </view>
<view class="menu-item" @click="showInvitesPopup"> <view class="menu-item" @click="toInvitesPage">
<view class="menu-icon-box"> <view class="menu-icon-box">
<image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNyAyMXYtMmE0IDQgMCAwIDAtNC00SDdhNCA0IDAgMCAwLTQgNHYyIj48L3BhdGg+PGNpcmNsZSBjeD0iMTAiIGN5PSI3IiByPSI0Ij48L2NpcmNsZT48cGF0aCBkPSJNMjMgMjF2LTJhNCA0IDAgMCAwLTMtMy4yNyI+PC9wYXRoPjxwYXRoIGQ9Ik0xNiAzLjEzYTQgNCAwIDAgMSAwIDcuNzUiPjwvcGF0aD48L3N2Zz4=" mode="aspectFit"></image> <image class="menu-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNyAyMXYtMmE0IDQgMCAwIDAtNC00SDdhNCA0IDAgMCAwLTQgNHYyIj48L3BhdGg+PGNpcmNsZSBjeD0iMTAiIGN5PSI3IiByPSI0Ij48L2NpcmNsZT48cGF0aCBkPSJNMjMgMjF2LTJhNCA0IDAgMCAwLTMtMy4yNyI+PC9wYXRoPjxwYXRoIGQ9Ik0xNiAzLjEzYTQgNCAwIDAgMSAwIDcuNzUiPjwvcGF0aD48L3N2Zz4=" mode="aspectFit"></image>
</view> </view>
@ -462,6 +463,7 @@ export default {
userId: '', userId: '',
nickname: '', nickname: '',
avatar: '', avatar: '',
title: '', //
inviteCode: '', inviteCode: '',
pointsBalance: 0, pointsBalance: 0,
@ -584,6 +586,7 @@ export default {
this.nickname = cachedUser.nickname this.nickname = cachedUser.nickname
this.avatar = cachedUser.avatar this.avatar = cachedUser.avatar
this.inviteCode = cachedUser.invite_code this.inviteCode = cachedUser.invite_code
this.title = cachedUser.title || ''
this.pointsBalance = this.normalizePointsBalance(cachedUser.points_balance) this.pointsBalance = this.normalizePointsBalance(cachedUser.points_balance)
} else if (cachedUserId) { } else if (cachedUserId) {
this.userId = cachedUserId this.userId = cachedUserId
@ -610,6 +613,7 @@ export default {
this.userId = res.id this.userId = res.id
this.nickname = res.nickname this.nickname = res.nickname
this.avatar = res.avatar this.avatar = res.avatar
this.title = res.title || res.level_name || ''
this.inviteCode = res.invite_code this.inviteCode = res.invite_code
this.pointsBalance = this.normalizePointsBalance(res.points_balance) this.pointsBalance = this.normalizePointsBalance(res.points_balance)
uni.setStorageSync('user_info', res) uni.setStorageSync('user_info', res)
@ -629,6 +633,7 @@ export default {
this.userId = '' this.userId = ''
this.nickname = '' this.nickname = ''
this.avatar = '' this.avatar = ''
this.title = ''
this.pointsBalance = 0 this.pointsBalance = 0
this.stats = { coupon_count: 0, item_card_count: 0 } this.stats = { coupon_count: 0, item_card_count: 0 }
}, },
@ -645,6 +650,21 @@ export default {
toAddresses() { toAddresses() {
uni.navigateTo({ url: '/pages/address/index' }) uni.navigateTo({ url: '/pages/address/index' })
}, },
toPointsPage() {
uni.navigateTo({ url: '/pages/points/index' })
},
toCouponsPage() {
uni.navigateTo({ url: '/pages/coupons/index' })
},
toItemCardsPage() {
uni.navigateTo({ url: '/pages/item-cards/index' })
},
toInvitesPage() {
uni.navigateTo({ url: '/pages/invites/index' })
},
toTasksPage() {
uni.navigateTo({ url: '/pages/tasks/index' })
},
toHelp() { toHelp() {
uni.showActionSheet({ uni.showActionSheet({
itemList: ['购买协议', '用户协议'], itemList: ['购买协议', '用户协议'],

View File

@ -1,5 +1,7 @@
<template> <template>
<view class="page-container"> <view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 加载状态 --> <!-- 加载状态 -->
<view v-if="loading" class="loading-state"> <view v-if="loading" class="loading-state">
<view class="loading-spinner"></view> <view class="loading-spinner"></view>
@ -64,7 +66,9 @@
<view class="item-meta"> <view class="item-meta">
<view class="price-wrap"> <view class="price-wrap">
<text class="currency" v-if="item.price > 0">¥</text> <text class="currency" v-if="item.price > 0">¥</text>
<text class="price">{{ item.price > 0 ? formatPrice(item.price) : '奖品' }}</text> <text class="price" v-if="item.price > 0">{{ formatPrice(item.price) }}</text>
<text class="price" v-else-if="order.points_amount > 0">{{ Math.floor(order.points_amount / (order.items && order.items.length || 1)) }}积分</text>
<text class="price" v-else>奖品</text>
</view> </view>
<text class="item-quantity">x{{ item.quantity }}</text> <text class="item-quantity">x{{ item.quantity }}</text>
</view> </view>
@ -86,9 +90,11 @@
<text class="tag" v-if="order.issue_number">{{ order.issue_number }}</text> <text class="tag" v-if="order.issue_number">{{ order.issue_number }}</text>
</view> </view>
<view class="item-meta"> <view class="item-meta">
<view class="price-wrap"> <view class="price-wrap">
<text class="currency" v-if="order.actual_amount > 0">¥</text> <text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="price">{{ order.actual_amount > 0 ? formatPrice(order.actual_amount) : '奖品' }}</text> <text class="price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
<text class="price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="price" v-else>奖品</text>
</view> </view>
<text class="item-quantity">x1</text> <text class="item-quantity">x1</text>
</view> </view>
@ -128,7 +134,8 @@
<view class="section-card amount-section"> <view class="section-card amount-section">
<view class="info-row"> <view class="info-row">
<text class="label">商品总额</text> <text class="label">商品总额</text>
<text class="value">¥{{ formatPrice(order.total_amount) }}</text> <text class="value" v-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="value" v-else>¥{{ formatPrice(order.total_amount) }}</text>
</view> </view>
<!-- 优惠券 --> <!-- 优惠券 -->
@ -159,7 +166,9 @@
<text class="total-label">{{ order.actual_amount > 0 ? '实付款' : '状态' }}</text> <text class="total-label">{{ order.actual_amount > 0 ? '实付款' : '状态' }}</text>
<view class="total-price-wrap"> <view class="total-price-wrap">
<text class="currency" v-if="order.actual_amount > 0">¥</text> <text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="total-price">{{ order.actual_amount > 0 ? formatPrice(order.actual_amount) : '无需支付' }}</text> <text class="total-price" v-if="order.actual_amount > 0">{{ formatPrice(order.actual_amount) }}</text>
<text class="total-price" v-else-if="order.points_amount > 0">{{ order.points_amount }}积分</text>
<text class="total-price" v-else>无需支付</text>
</view> </view>
</view> </view>
</view> </view>
@ -410,8 +419,47 @@ function showProofHelp() {
.page-container { .page-container {
min-height: 100vh; min-height: 100vh;
background: $bg-page; background: $bg-page;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); padding-bottom: calc(140rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
position: relative; position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
} }
/* 状态头部背景 */ /* 状态头部背景 */
@ -420,6 +468,7 @@ function showProofHelp() {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 0 0 40rpx 40rpx; border-radius: 0 0 40rpx 40rpx;
z-index: 1;
&.status-pending { background: linear-gradient(135deg, #FF9F43 0%, #FF6B6B 100%); } &.status-pending { background: linear-gradient(135deg, #FF9F43 0%, #FF6B6B 100%); }
&.status-completed { background: linear-gradient(135deg, #2ECC71 0%, #27AE60 100%); } &.status-completed { background: linear-gradient(135deg, #2ECC71 0%, #27AE60 100%); }
@ -428,7 +477,7 @@ function showProofHelp() {
.bg-circle { .bg-circle {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.15);
&.c1 { width: 300rpx; height: 300rpx; top: -100rpx; right: -50rpx; } &.c1 { width: 300rpx; height: 300rpx; top: -100rpx; right: -50rpx; }
&.c2 { width: 200rpx; height: 200rpx; bottom: 50rpx; left: -50rpx; } &.c2 { width: 200rpx; height: 200rpx; bottom: 50rpx; left: -50rpx; }
@ -438,13 +487,14 @@ function showProofHelp() {
/* 状态卡片 */ /* 状态卡片 */
.status-card { .status-card {
margin: -60rpx $spacing-lg 0; margin: -60rpx $spacing-lg 0;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px); backdrop-filter: blur(20rpx);
border-radius: $radius-xl; border-radius: $radius-xl;
padding: $spacing-xl; padding: $spacing-xl;
box-shadow: $shadow-card; box-shadow: $shadow-card;
position: relative; position: relative;
z-index: 10; z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.6);
.status-content { .status-content {
display: flex; display: flex;
@ -488,11 +538,15 @@ function showProofHelp() {
/* 通用卡片样式 */ /* 通用卡片样式 */
.section-card { .section-card {
margin: $spacing-lg; margin: $spacing-lg;
background: $bg-card; background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20rpx);
border-radius: $radius-lg; border-radius: $radius-lg;
padding: $spacing-lg; padding: $spacing-lg;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
animation: slideUp 0.4s ease-out; animation: slideUp 0.4s ease-out;
border: 1px solid rgba(255, 255, 255, 0.6);
position: relative;
z-index: 5;
} }
@keyframes slideUp { @keyframes slideUp {

View File

@ -1,7 +1,9 @@
<template> <template>
<view class="page-container"> <view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<!-- 顶部 Tab --> <!-- 顶部 Tab -->
<view class="tabs"> <view class="tabs glass-card">
<view <view
class="tab-item" class="tab-item"
:class="{ active: currentTab === 'pending' }" :class="{ active: currentTab === 'pending' }"
@ -161,7 +163,10 @@ function formatTime(t) {
return `${m}-${day} ${hh}:${mm}` return `${m}-${day} ${hh}:${mm}`
} }
function formatAmount(a) { function formatAmount(a, item) {
if (item && item.points_amount > 0) {
return `${item.points_amount}积分`
}
if (a === undefined || a === null) return '¥0.00' if (a === undefined || a === null) return '¥0.00'
const n = Number(a) const n = Number(a)
if (Number.isNaN(n)) return '¥0.00' if (Number.isNaN(n)) return '¥0.00'
@ -175,6 +180,7 @@ function shouldShowAmountLabel(item) {
} }
function getAmountText(item) { function getAmountText(item) {
if (item.points_amount > 0) return formatAmount(0, item)
const amount = item.actual_amount || item.total_amount const amount = item.actual_amount || item.total_amount
if (amount > 0) return formatAmount(amount) if (amount > 0) return formatAmount(amount)
@ -283,6 +289,7 @@ function getStatusClass(item) {
function switchTab(tab) { function switchTab(tab) {
if (currentTab.value === tab) return if (currentTab.value === tab) return
uni.vibrateShort({ type: 'light' })
currentTab.value = tab currentTab.value = tab
fetchOrders(false) fetchOrders(false)
} }
@ -448,23 +455,26 @@ onReachBottom(() => {
min-height: 100vh; min-height: 100vh;
background: $bg-page; background: $bg-page;
position: relative; position: relative;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); padding-bottom: calc(40rpx + env(safe-area-inset-top) + env(safe-area-inset-bottom));
overflow: hidden;
} }
/* 顶部 Tab - 与盒柜页面保持一致 */ /* 顶部 Tab */
.tabs { .tabs {
@extend .glass-card;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 88rpx; height: 88rpx;
background: rgba($bg-card, 0.95);
backdrop-filter: blur(20rpx);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05); border-radius: 0;
border-top: none;
border-left: none;
border-right: none;
} }
.tab-item { .tab-item {
@ -614,16 +624,19 @@ onReachBottom(() => {
/* 订单卡片 */ /* 订单卡片 */
.order-card { .order-card {
background: $bg-card; background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10rpx);
border-radius: $radius-xl; border-radius: $radius-xl;
overflow: hidden; overflow: hidden;
box-shadow: $shadow-card; box-shadow: $shadow-sm;
animation: fadeInUp 0.4s ease-out backwards; animation: fadeInUp 0.4s ease-out backwards;
animation-delay: var(--delay, 0s); animation-delay: var(--delay, 0s);
transition: all 0.2s; transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.6);
&:active { &:active {
transform: scale(0.98); transform: scale(0.98);
box-shadow: none;
} }
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<view class="wrap"> <view class="wrap">
<!-- 顶部装饰背景 --> <!-- 顶部装饰背景 - 漂浮光球 -->
<view class="page-bg-decoration"></view> <view class="bg-decoration"></view>
<view class="header-area"> <view class="header-area">
<view class="page-title">积分明细</view> <view class="page-title">积分明细</view>
@ -26,14 +26,14 @@
class="record-item" class="record-item"
:style="{ animationDelay: `${index * 0.05}s` }" :style="{ animationDelay: `${index * 0.05}s` }"
> >
<view class="record-icon" :class="{ 'is-add': (item.change || item.amount || 0) > 0 }"> <view class="record-icon" :class="{ 'is-add': (item.points || 0) > 0 }">
{{ (item.change || item.amount || 0) > 0 ? '↓' : '↑' }} {{ (item.points || 0) > 0 ? '↓' : '↑' }}
</view> </view>
<view class="record-content"> <view class="record-content">
<view class="record-main"> <view class="record-main">
<view class="record-title">{{ item.title || item.reason || '积分变更' }}</view> <view class="record-title">{{ getActionText(item.action) || item.title || item.reason || '积分变更' }}</view>
<view class="record-amount" :class="{ inc: (item.change || item.amount || 0) > 0, dec: (item.change || item.amount || 0) < 0 }"> <view class="record-amount" :class="{ inc: (item.points || 0) > 0, dec: (item.points || 0) < 0 }">
{{ (item.change ?? item.amount ?? 0) > 0 ? '+' : '' }}{{ item.change ?? item.amount ?? 0 }} {{ (item.points ?? 0) > 0 ? '+' : '' }}{{ item.points ?? 0 }}
</view> </view>
</view> </view>
<view class="record-footer"> <view class="record-footer">
@ -77,6 +77,26 @@ function formatTime(t) {
return `${y}-${m}-${day} ${hh}:${mm}` return `${y}-${m}-${day} ${hh}:${mm}`
} }
function getActionText(action) {
const map = {
'signin': '每日签到',
'register': '注册赠送',
'invite_reward': '邀请奖励',
'order_deduct': '下单抵扣',
'consume_order': '下单消费',
'refund_restore': '退款返还',
'refund_points': '积分退回',
'refund_amount': '金额退款奖励',
'manual_add': '管理手动增加',
'manual': '系统调整',
'redeem_coupon': '兑换优惠券',
'redeem_product': '兑换商品',
'redeem_reward': '奖品兑换积分',
'redeem_item_card': '兑换道具卡'
}
return map[action] || ''
}
async function fetchRecords(append = false) { async function fetchRecords(append = false) {
const user_id = uni.getStorageSync('user_id') const user_id = uni.getStorageSync('user_id')
const token = uni.getStorageSync('token') const token = uni.getStorageSync('token')
@ -106,7 +126,7 @@ async function fetchRecords(append = false) {
error.value = '' error.value = ''
try { try {
const list = await getPointsRecords(user_id, page.value, pageSize.value) const list = await getPointsRecords(user_id, page.value, pageSize.value)
const items = Array.isArray(list) ? list : (list && list.items) || [] const items = Array.isArray(list) ? list : (list && (list.list || list.items)) || []
const total = (list && list.total) || 0 const total = (list && list.total) || 0
if (append) { if (append) {
records.value = records.value.concat(items) records.value = records.value.concat(items)
@ -143,33 +163,27 @@ onReachBottom(() => {
min-height: 100vh; min-height: 100vh;
background-color: $bg-page; background-color: $bg-page;
position: relative; position: relative;
overflow-x: hidden; overflow: hidden;
} }
.page-bg-decoration { /* 背景装饰 - 漂浮光球 (与个人中心统一) */
position: absolute;
top: -200rpx;
right: -200rpx;
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15), transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.header-area { .header-area {
padding: $spacing-xl $spacing-lg; padding: $spacing-xl $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + 20rpx);
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.page-title { .page-title {
font-size: 48rpx; font-size: 48rpx;
font-weight: 900; font-weight: 900;
color: $text-main; color: $text-main;
margin-bottom: 8rpx; margin-bottom: 8rpx;
letter-spacing: 1rpx; letter-spacing: 1rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
} }
.page-subtitle { .page-subtitle {
font-size: 24rpx; font-size: 24rpx;
color: $text-tertiary; color: $text-tertiary;

View File

@ -7,14 +7,15 @@
<view class="info-card"> <view class="info-card">
<view class="title">{{ detail.title || detail.name || '-' }}</view> <view class="title">{{ detail.title || detail.name || '-' }}</view>
<view class="price-row"> <view class="price-row">
<view class="points-wrap" v-if="detail.points_required"> <view class="points-wrap">
<text class="points-val">{{ detail.points_required }}</text> <text class="points-val">{{ detail.points_required || (detail.price ? Math.floor(detail.price / 100) : 0) }}</text>
<text class="points-unit">积分</text> <text class="points-unit">积分</text>
</view> </view>
<text class="price" v-else>¥{{ formatPrice(detail.price_sale || detail.price) }}</text>
</view> </view>
<view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存{{ detail.stock }}</view> <view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存{{ detail.stock }}</view>
<view class="desc" v-if="detail.description">{{ detail.description }}</view> <view class="desc" v-if="detail.description">
<rich-text :nodes="detail.description"></rich-text>
</view>
</view> </view>
</view> </view>
@ -23,8 +24,7 @@
<!-- Action Bar Moved Outside info-card --> <!-- Action Bar Moved Outside info-card -->
<view class="action-bar-placeholder" v-if="detail.id"></view> <view class="action-bar-placeholder" v-if="detail.id"></view>
<view class="action-bar" v-if="detail.id"> <view class="action-bar" v-if="detail.id">
<view class="action-btn redeem" v-if="detail.points_required" @tap="onRedeem">立即兑换</view> <view class="action-btn redeem" @tap="onRedeem">立即兑换</view>
<view class="action-btn buy" v-else @tap="onBuy">立即购买</view>
</view> </view>
</view> </view>
</template> </template>
@ -74,9 +74,10 @@ async function onRedeem() {
return return
} }
const points = detail.value.points_required || (detail.value.price ? Math.floor(detail.value.price / 100) : 0)
uni.showModal({ uni.showModal({
title: '确认兑换', title: '确认兑换',
content: `是否消耗 ${p.points_required} 积分兑换 ${p.title}`, content: `是否消耗 ${points} 积分兑换 ${p.title || p.name}`,
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
uni.showLoading({ title: '兑换中...' }) uni.showLoading({ title: '兑换中...' })
@ -85,16 +86,18 @@ async function onRedeem() {
if (!userId) throw new Error('用户ID不存在') if (!userId) throw new Error('用户ID不存在')
await redeemProductByPoints(userId, p.id, 1) await redeemProductByPoints(userId, p.id, 1)
uni.showToast({ title: '兑换成功', icon: 'success' })
// Refresh detail
setTimeout(() => {
fetchDetail(p.id)
}, 1500)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
uni.hideLoading() uni.hideLoading()
uni.showModal({
title: '兑换成功',
content: `您已成功兑换 ${p.title || p.name}`,
showCancel: false,
success: () => {
fetchDetail(p.id)
}
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} }
} }
} }
@ -116,6 +119,46 @@ onLoad((opts) => {
min-height: 100vh; min-height: 100vh;
background: $bg-page; background: $bg-page;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
position: relative;
overflow: hidden;
}
/* 背景装饰 - 漂浮光球 (与各主要页面统一) */
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
} }
.loading, .empty { .loading, .empty {
@ -123,30 +166,37 @@ onLoad((opts) => {
padding: 120rpx 40rpx; padding: 120rpx 40rpx;
color: $text-secondary; color: $text-secondary;
font-size: $font-md; font-size: $font-md;
position: relative;
z-index: 10;
} }
.detail-wrap { .detail-wrap {
padding-bottom: 40rpx; padding-bottom: 40rpx;
animation: fadeInUp 0.4s ease-out; animation: fadeInUp 0.4s ease-out;
position: relative;
z-index: 1;
} }
.main-image { .main-image {
width: 100%; width: 100%;
height: 750rpx; /* Square aspect ratio */ height: 750rpx;
display: block; display: block;
background: $bg-secondary; background: $bg-secondary;
box-shadow: $shadow-sm;
} }
.info-card { .info-card {
background: #fff; background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20rpx);
border-radius: $radius-xl $radius-xl 0 0; border-radius: $radius-xl $radius-xl 0 0;
padding: $spacing-xl; padding: $spacing-xl;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05); box-shadow: 0 -8rpx 32rpx rgba(0,0,0,0.05);
position: relative; position: relative;
z-index: 2; z-index: 2;
margin-top: -40rpx; /* Slight overlap */ margin-top: -40rpx;
min-height: 50vh; min-height: 50vh;
border-top: 1px solid rgba(255, 255, 255, 0.6);
border-left: 1px solid rgba(255, 255, 255, 0.6);
border-right: 1px solid rgba(255, 255, 255, 0.6);
} }
.title { .title {
@ -155,6 +205,7 @@ onLoad((opts) => {
color: $text-main; color: $text-main;
margin-bottom: $spacing-md; margin-bottom: $spacing-md;
line-height: 1.4; line-height: 1.4;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
} }
.price-row { .price-row {
@ -197,7 +248,7 @@ onLoad((opts) => {
font-size: $font-sm; font-size: $font-sm;
color: $text-secondary; color: $text-secondary;
margin-bottom: $spacing-lg; margin-bottom: $spacing-lg;
background: $bg-secondary; background: rgba(0,0,0,0.05);
display: inline-block; display: inline-block;
padding: 6rpx $spacing-md; padding: 6rpx $spacing-md;
border-radius: $radius-sm; border-radius: $radius-sm;
@ -218,6 +269,12 @@ onLoad((opts) => {
margin-bottom: $spacing-sm; margin-bottom: $spacing-sm;
font-weight: 700; font-weight: 700;
} }
:deep(img) {
max-width: 100%;
height: auto;
display: block;
}
} }
.action-bar-placeholder { height: 120rpx; } .action-bar-placeholder { height: 120rpx; }
@ -231,8 +288,9 @@ onLoad((opts) => {
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05); box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
z-index: 10; z-index: 100;
} }
.action-btn { .action-btn {
width: 100%; width: 100%;
height: 88rpx; height: 88rpx;
@ -243,6 +301,9 @@ onLoad((opts) => {
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
transition: transform 0.2s;
&:active { transform: scale(0.96); }
} }
.action-btn.redeem { background: linear-gradient(135deg, #FFB74D, #FF9800); box-shadow: 0 8rpx 20rpx rgba(255, 152, 0, 0.3); } .action-btn.redeem { background: linear-gradient(135deg, #FFB74D, #FF9800); box-shadow: 0 8rpx 20rpx rgba(255, 152, 0, 0.3); }
.action-btn.buy { background: linear-gradient(135deg, #FF6B6B, #FF3B30); box-shadow: 0 8rpx 20rpx rgba(255, 59, 48, 0.3); } .action-btn.buy { background: linear-gradient(135deg, #FF6B6B, #FF3B30); box-shadow: 0 8rpx 20rpx rgba(255, 59, 48, 0.3); }

View File

@ -1,59 +1,112 @@
<template> <template>
<view class="page"> <view class="page">
<view class="bg-decoration"></view> <view class="bg-decoration"></view>
<!-- 顶部固定区域 --> <!-- 顶部固定区域 -->
<view class="header-section"> <view class="header-section">
<view class="search-box"> <view class="search-box">
<view class="search-input-wrap"> <view class="search-input-wrap">
<text class="search-icon">🔍</text> <text class="search-icon">🔍</text>
<input class="search-input" v-model="keyword" placeholder="搜索心仪的商品" placeholder-class="placeholder-style" confirm-type="search" @confirm="onSearchConfirm" /> <input class="search-input" v-model="keyword" placeholder="搜索心仪的宝贝" placeholder-class="placeholder-style" confirm-type="search" @confirm="onSearchConfirm" />
</view> </view>
</view> </view>
<view class="filter-row"> <!-- 分类标签 -->
<view class="price-range"> <view class="tab-row">
<text class="price-label">价格区间</text> <view
<input class="price-input" type="number" v-model="minPrice" placeholder="最低" placeholder-class="price-ph" /> v-for="tab in tabs"
<text class="price-sep">-</text> :key="tab.id"
<input class="price-input" type="number" v-model="maxPrice" placeholder="最高" placeholder-class="price-ph" /> class="tab-item"
:class="{ active: currentTab === tab.id }"
@tap="switchTab(tab.id)"
>
<text class="tab-text">{{ tab.name }}</text>
<view class="tab-line" v-if="currentTab === tab.id"></view>
</view> </view>
<button class="filter-btn" hover-class="btn-hover" @tap="onApplyFilters">筛选</button>
</view> </view>
</view> </view>
<!-- 占位防止内容被头部遮挡 --> <!-- 占位 -->
<view class="header-placeholder"></view> <view class="header-placeholder"></view>
<view v-if="loading && !products.length" class="loading-wrap"><view class="spinner"></view></view> <view v-if="loading && !items.length" class="loading-wrap"><view class="spinner"></view></view>
<view class="products-container" v-else> <view class="content-container" v-else>
<view v-if="products.length > 0" class="products-grid"> <!-- 商品视图 (网格) -->
<view class="product-item" v-for="p in products" :key="p.id" @tap="onProductTap(p)"> <view v-if="currentTab === 'product'" class="products-grid">
<view class="product-item" v-for="p in items" :key="p.id" @tap="onProductTap(p)">
<view class="product-card"> <view class="product-card">
<view class="thumb-wrap"> <view class="thumb-wrap">
<image class="product-thumb" :src="p.image" mode="aspectFill" lazy-load="true" /> <image class="product-thumb" :src="p.image" mode="aspectFill" lazy-load="true" />
<view class="stock-tag" v-if="p.stock !== null && p.stock < 10">仅剩{{p.stock}}</view> <view class="stock-tag" v-if="p.stock !== null && p.stock < 10 && p.stock > 0">仅剩{{p.stock}}</view>
<view class="stock-tag out" v-else-if="p.stock === 0">已罄</view>
</view> </view>
<view class="product-info"> <view class="product-info">
<text class="product-title">{{ p.title }}</text> <text class="product-title">{{ p.title }}</text>
<view class="product-bottom"> <view class="product-bottom">
<view class="price-row" v-if="p.points"> <view class="price-row">
<text class="points-val">{{ p.points }}</text> <text class="points-val">{{ p.points || 0 }}</text>
<text class="points-unit">积分</text> <text class="points-unit">积分</text>
</view> </view>
<view class="price-row" v-else> <view class="redeem-btn" @tap.stop="onRedeemTap(p)">兑换</view>
<text class="price-symbol"></text>
<text class="price-val">{{ p.price }}</text>
</view>
<view class="redeem-btn" v-if="p.points" @tap.stop="onRedeemTap(p)">兑换</view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view v-else class="empty-state">
<!-- 优惠券视图 (票据样式列表) -->
<view v-else-if="currentTab === 'coupon'" class="coupons-list">
<view class="coupon-card" v-for="c in items" :key="c.id">
<view class="coupon-left">
<view class="coupon-val-row">
<text class="coupon-symbol">¥</text>
<text class="coupon-val">{{ (c.discount_value || 0) / 100 }}</text>
</view>
<text class="coupon-limit" v-if="c.min_spend > 0">{{ (c.min_spend || 0) / 100 }}可用</text>
<text class="coupon-limit" v-else>无门槛</text>
</view>
<view class="coupon-divider">
<view class="notch top"></view>
<view class="dash"></view>
<view class="notch bottom"></view>
</view>
<view class="coupon-right">
<view class="coupon-name">{{ c.title || c.name }}</view>
<view class="coupon-bottom">
<view class="coupon-price">
<text class="p-val">{{ c.points || 0 }}</text>
<text class="p-unit">积分</text>
</view>
<view class="coupon-btn" @tap="onRedeemTap(c)">立即兑换</view>
</view>
</view>
</view>
</view>
<!-- 道具卡视图 (列表卡片) -->
<view v-else-if="currentTab === 'item_card'" class="item-cards-list">
<view class="item-card" v-for="ic in items" :key="ic.id">
<view class="ic-icon-wrap">
<text class="ic-icon">🃏</text>
</view>
<view class="ic-info">
<text class="ic-name">{{ ic.title || ic.name }}</text>
<text class="ic-desc">{{ ic.description || '可在抽奖时使用' }}</text>
<view class="ic-bottom">
<view class="ic-price">
<text class="p-val">{{ ic.points || 0 }}</text>
<text class="p-unit">积分</text>
</view>
<view class="ic-btn" @tap="onRedeemTap(ic)">兑换</view>
</view>
</view>
</view>
</view>
<view v-if="!items.length" class="empty-state">
<image class="empty-img" src="/static/empty.png" mode="widthFix" /> <image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂无相关商品</text> <text class="empty-text">暂无相关兑换项</text>
</view> </view>
</view> </view>
</view> </view>
@ -61,22 +114,20 @@
<script setup> <script setup>
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { ref, computed } from 'vue' import { ref, watch } from 'vue'
import { request, authRequest } from '../../utils/request.js' import { getStoreItems, redeemProductByPoints, redeemCouponByPoints, redeemItemCardByPoints } from '../../api/appUser'
const products = ref([])
const CACHE_KEY = 'products_cache_v1'
const TTL_MS = 10 * 60 * 1000
const loading = ref(false) const loading = ref(false)
const keyword = ref('') const keyword = ref('')
const minPrice = ref('') const currentTab = ref('product')
const maxPrice = ref('') const items = ref([])
const allItems = ref([])
function apiGet(url, data = {}) { const tabs = [
const token = uni.getStorageSync('token') { id: 'product', name: '商品' },
const fn = token ? authRequest : request { id: 'coupon', name: '优惠券' },
return fn({ url, method: 'GET', data }) { id: 'item_card', name: '道具卡' }
} ]
function cleanUrl(u) { function cleanUrl(u) {
const s = String(u || '').trim() const s = String(u || '').trim()
@ -85,31 +136,62 @@ function cleanUrl(u) {
return s.replace(/[`'\"]/g, '').trim() return s.replace(/[`'\"]/g, '').trim()
} }
function normalizeProducts(list) { function normalizeItems(list, kind) {
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
return list.map((i, idx) => ({ return list.map((i, idx) => ({
id: i.id ?? i.productId ?? i._id ?? i.sku_id ?? String(idx), id: i.id,
image: cleanUrl(i.main_image ?? i.imageUrl ?? i.image_url ?? i.image ?? i.img ?? i.pic ?? ''), kind: i.kind || kind,
title: i.title ?? i.name ?? i.product_name ?? i.sku_name ?? '', image: cleanUrl(i.main_image || i.image || ''),
price: (() => { const raw = i.price_sale ?? i.price ?? i.price_min ?? i.amount ?? null; return raw == null ? null : (Number(raw) / 100) })(), title: i.name || i.title || '',
points: i.points_required ?? i.points ?? i.integral ?? null, price: i.price || i.discount_value || 0,
stock: i.stock ?? i.inventory ?? i.quantity ?? null, points: i.points_required || (i.price ? Math.floor(i.price / 100) : (i.discount_value ? Math.floor(i.discount_value / 100) : 0)),
link: cleanUrl(i.linkUrl ?? i.link_url ?? i.link ?? i.url ?? '') stock: i.in_stock ? 99 : 0, // Simplified stock check if returned as bool
})).filter(i => i.image || i.title) discount_value: i.discount_value || 0,
min_spend: i.min_spend || 0,
description: i.description || ''
})).filter(i => i.title)
} }
function switchTab(id) {
if (currentTab.value === id) return
currentTab.value = id
loading.value = true
items.value = []
loadItems()
}
async function loadItems() {
loading.value = true
try {
const res = await getStoreItems(currentTab.value, 1, 100)
const list = res.list || res || []
allItems.value = normalizeItems(list, currentTab.value)
applyFilters()
} catch (e) {
console.error(e)
items.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
items.value = allItems.value.filter(item => {
const title = String(item.title || '').toLowerCase()
return !k || title.includes(k)
})
}
function onSearchConfirm() { applyFilters() }
function onProductTap(p) { function onProductTap(p) {
const id = p && p.id if (p.kind === 'product') {
if (id !== undefined && id !== null && id !== '') { uni.navigateTo({ url: `/pages/shop/detail?id=${p.id}` })
uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
return
}
if (p.link && /^\/.+/.test(p.link)) {
uni.navigateTo({ url: p.link })
} }
} }
async function onRedeemTap(p) { async function onRedeemTap(item) {
const token = uni.getStorageSync('token') const token = uni.getStorageSync('token')
if (!token) { if (!token) {
uni.showModal({ uni.showModal({
@ -123,150 +205,51 @@ async function onRedeemTap(p) {
uni.showModal({ uni.showModal({
title: '确认兑换', title: '确认兑换',
content: `是否消耗 ${p.points} 积分兑换 ${p.title}`, content: `是否消耗 ${item.points} 积分兑换 ${item.title}`,
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
uni.showLoading({ title: '兑换中...' }) uni.showLoading({ title: '兑换中...' })
try { try {
// Get user_id from storage
const userId = uni.getStorageSync('user_id') const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在') if (!userId) throw new Error('用户ID不存在')
await redeemProductByPoints(userId, p.id, 1) if (item.kind === 'product') {
uni.showToast({ title: '兑换成功', icon: 'success' }) await redeemProductByPoints(userId, item.id, 1)
} else if (item.kind === 'coupon') {
await redeemCouponByPoints(userId, item.id)
} else if (item.kind === 'item_card') {
await redeemItemCardByPoints(userId, item.id, 1)
}
// Refresh products to update stock/points if needed
setTimeout(() => {
loadProducts()
}, 1500)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
uni.hideLoading() uni.hideLoading()
uni.showModal({
title: '兑换成功',
content: `您已成功兑换 ${item.title || item.name}`,
showCancel: false,
success: () => {
loadItems()
}
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} }
} }
} }
}) })
} }
// Filter logic onShow(() => {
const allProducts = ref([]) // Store all fetched products for client-side filtering
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
const min = Number(minPrice.value)
const max = Number(maxPrice.value)
const hasMin = !isNaN(min) && String(minPrice.value).trim() !== ''
const hasMax = !isNaN(max) && String(maxPrice.value).trim() !== ''
const list = allProducts.value
products.value = list.filter(p => {
const title = String(p.title || '').toLowerCase()
if (k && !title.includes(k)) return false
const priceNum = typeof p.price === 'number' ? p.price : Number(p.price)
if (hasMin) {
if (isNaN(priceNum)) return false
if (priceNum < min) return false
}
if (hasMax) {
if (isNaN(priceNum)) return false
if (priceNum > max) return false
}
return true
})
}
function onSearchConfirm() { applyFilters() }
function onApplyFilters() { applyFilters() }
async function loadProducts() {
try {
const cached = uni.getStorageSync(CACHE_KEY)
if (cached && cached.data && Date.now() - cached.ts < TTL_MS) {
allProducts.value = cached.data
applyFilters()
return
}
const first = await apiGet('/api/app/products', { page: 1 })
// Simple extraction
let list = []
let total = 0
if (first && first.list) { list = first.list; total = first.total }
else if (first && first.data && first.data.list) { list = first.data.list; total = first.data.total }
// If not too many, fetch all for better client UX
const pageSize = 20
const totalPages = Math.ceil((total || 0) / pageSize)
if (totalPages > 1) {
const tasks = []
for (let p = 2; p <= totalPages; p++) {
tasks.push(apiGet('/api/app/products', { page: p }))
}
const results = await Promise.allSettled(tasks)
results.forEach(r => {
if (r.status === 'fulfilled') {
const val = r.value
const subList = (val && val.list) || (val && val.data && val.data.list) || []
if (Array.isArray(subList)) list = list.concat(subList)
}
})
}
const normalized = normalizeProducts(list)
allProducts.value = normalized
applyFilters()
uni.setStorageSync(CACHE_KEY, { data: normalized, ts: Date.now() })
} catch (e) {
console.error(e)
products.value = []
}
}
onShow(async () => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound') const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) { if (token && phoneBound) {
uni.showModal({ loadItems()
title: '提示', } else {
content: '请先登录并绑定手机号', // Redirect logic if needed
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return
}
loading.value = true
await loadProducts()
loading.value = false
})
//
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
onShareAppMessage(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '柯大鸭潮玩商城 - 好物等你来兑',
path: `/pages/index/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
} }
}) })
onShareTimeline(() => { watch(keyword, () => applyFilters())
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '柯大鸭潮玩商城 - 好物等你来兑',
query: `invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
})
</script> </script>
@ -279,302 +262,179 @@ onShareTimeline(() => {
overflow-x: hidden; overflow-x: hidden;
} }
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100vh;
pointer-events: none;
z-index: 0;
&::before {
content: '';
position: absolute;
top: -100rpx; left: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
bottom: 10%; right: -100rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.5;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
/* 顶部 Header */ /* 顶部 Header */
.header-section { .header-section {
position: fixed; position: fixed;
top: 0; top: 0; left: 0; right: 0;
left: 0;
right: 0;
z-index: 100; z-index: 100;
background: $bg-glass; background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20rpx); backdrop-filter: blur(20rpx);
padding: 0 24rpx 24rpx; padding: 20rpx 24rpx 0;
box-shadow: $shadow-sm; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
border-bottom: 1rpx solid $border-color-light;
}
.header-placeholder {
height: 160rpx; /* 根据 header 高度调整 */
} }
.header-placeholder { height: 180rpx; }
.page-title { .search-box { margin-bottom: 20rpx; }
font-size: 36rpx;
font-weight: 800;
color: $text-main;
padding: 20rpx 0;
}
/* 搜索框 */
.search-box {
margin-bottom: 20rpx;
margin-top: 20rpx;
}
.search-input-wrap { .search-input-wrap {
display: flex; display: flex; align-items: center;
align-items: center; background: rgba(0, 0, 0, 0.04);
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: $radius-round; border-radius: $radius-round;
padding: 18rpx 24rpx; padding: 16rpx 24rpx;
transition: all 0.3s;
}
.search-input-wrap:focus-within {
background: $bg-card;
border-color: $brand-primary;
box-shadow: 0 0 0 4rpx rgba($brand-primary, 0.1);
}
.search-icon {
font-size: 28rpx;
margin-right: 16rpx;
opacity: 0.5;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: $text-main;
}
.placeholder-style {
color: $text-tertiary;
} }
.search-icon { font-size: 28rpx; margin-right: 16rpx; opacity: 0.5; }
.search-input { flex: 1; font-size: 26rpx; color: $text-main; }
.placeholder-style { color: $text-tertiary; }
/* 筛选行 */ /* Tabs */
.filter-row { .tab-row {
display: flex; display: flex;
align-items: center; justify-content: space-around;
justify-content: space-between; padding-bottom: 4rpx;
gap: 20rpx;
} }
.price-range { .tab-item {
flex: 1; position: relative;
padding: 16rpx 20rpx;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
background: $bg-secondary;
border-radius: $radius-md;
padding: 10rpx 20rpx;
} }
.price-label { .tab-text {
font-size: 24rpx; font-size: 28rpx;
color: $text-sub; color: $text-secondary;
margin-right: 16rpx; font-weight: 500;
transition: all 0.2s;
} }
.price-input { .tab-item.active .tab-text {
flex: 1; color: $brand-primary;
font-size: 26rpx; font-weight: 700;
text-align: center; font-size: 30rpx;
color: $text-main;
} }
.price-ph { .tab-line {
color: $text-tertiary; position: absolute;
font-size: 24rpx; bottom: 0;
} width: 40rpx;
.price-sep { height: 6rpx;
color: $text-tertiary;
margin: 0 10rpx;
}
.filter-btn {
background: $gradient-brand; background: $gradient-brand;
color: $text-inverse; border-radius: 4rpx;
font-size: 26rpx;
font-weight: 600;
border-radius: $radius-md;
padding: 0 32rpx;
height: 64rpx;
line-height: 64rpx;
border: none;
box-shadow: $shadow-sm;
}
.btn-hover {
opacity: 0.9;
transform: scale(0.98);
} }
/* 商品 Grid 容器 */ .content-container { padding: 24rpx; }
.products-container {
padding: 24rpx; /* 商品 Grid */
}
.products-grid { .products-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 20rpx; gap: 20rpx;
} }
/* 商品卡片 */
.product-item {
animation: fadeInUp 0.5s ease-out backwards;
}
@for $i from 1 through 10 {
.product-item:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
.product-card { .product-card {
background: $bg-card; background: #fff;
border-radius: $radius-lg; border-radius: $radius-lg;
overflow: hidden; overflow: hidden;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.product-item:active .product-card {
transform: scale(0.98);
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
} }
.thumb-wrap { .thumb-wrap {
position: relative; position: relative; width: 100%; padding-top: 100%;
width: 100%; background: #f8f8f8;
padding-top: 100%; /* 1:1 Aspect Ratio */
background: $bg-secondary;
}
.product-thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
} }
.product-thumb { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.stock-tag { .stock-tag {
position: absolute; position: absolute; bottom: 0; right: 0;
bottom: 0; background: rgba($brand-primary, 0.9);
right: 0; color: #fff; font-size: 20rpx; padding: 4rpx 12rpx;
background: rgba($accent-red, 0.9);
color: #fff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-top-left-radius: 12rpx; border-top-left-radius: 12rpx;
backdrop-filter: blur(4px); &.out { background: #999; }
font-weight: 700;
box-shadow: 0 -2rpx 8rpx rgba(0,0,0,0.1);
} }
.product-info { padding: 16rpx; }
.product-info {
padding: 20rpx;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-title { .product-title {
font-size: 28rpx; font-size: 26rpx; color: $text-main; font-weight: 600;
color: $text-main; line-height: 1.4; height: 2.8em; overflow: hidden;
line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
height: 2.8em; /* 2 lines */ margin-bottom: 12rpx;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 16rpx;
font-weight: 600;
} }
.product-bottom { display: flex; align-items: center; justify-content: space-between; }
.product-bottom { .points-val { font-size: 32rpx; font-weight: 800; color: #FF9800; font-family: 'DIN-Bold'; }
display: flex; .points-unit { font-size: 20rpx; color: #FF9800; margin-left: 2rpx; }
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 8rpx;
}
.price-row {
display: flex; align-items: baseline;
}
.points-val { font-size: 36rpx; font-weight: 700; color: #FF9800; }
.points-unit { font-size: 22rpx; color: #FF9800; margin-left: 4rpx; }
.price-symbol { font-size: 24rpx; color: #FF3B30; }
.price-val { font-size: 36rpx; font-weight: 700; color: #FF3B30; }
.redeem-btn { .redeem-btn {
background: #FF9800; background: $gradient-brand; color: #fff; font-size: 22rpx;
color: #fff; padding: 6rpx 18rpx; border-radius: 24rpx; font-weight: 600;
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 30rpx;
margin-left: auto;
} }
/* Loading & Empty */ /* 优惠券 (Ticket Style) */
.loading-wrap { .coupons-list { display: flex; flex-direction: column; gap: 24rpx; }
padding: 100rpx 0; .coupon-card {
display: flex; display: flex;
justify-content: center; background: #fff;
border-radius: $radius-lg;
height: 180rpx;
box-shadow: $shadow-sm;
overflow: hidden;
} }
.coupon-left {
width: 200rpx;
background: linear-gradient(135deg, #FF6B6B, #FF3B30);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #fff;
}
.coupon-val-row { display: flex; align-items: baseline; }
.coupon-symbol { font-size: 24rpx; font-weight: 600; }
.coupon-val { font-size: 56rpx; font-weight: 800; font-family: 'DIN-Bold'; }
.coupon-limit { font-size: 20rpx; opacity: 0.9; }
.coupon-divider {
width: 2rpx; position: relative;
background: #eee;
.notch {
position: absolute; width: 24rpx; height: 24rpx; background: $bg-page;
border-radius: 50%; left: -12rpx;
&.top { top: -12rpx; }
&.bottom { bottom: -12rpx; }
}
}
.coupon-right {
flex: 1; padding: 24rpx;
display: flex; flex-direction: column; justify-content: space-between;
}
.coupon-name { font-size: 30rpx; font-weight: 700; color: $text-main; }
.coupon-bottom { display: flex; align-items: center; justify-content: space-between; }
.coupon-price .p-val { font-size: 36rpx; font-weight: 800; color: #FF9800; }
.coupon-price .p-unit { font-size: 22rpx; color: #FF9800; }
.coupon-btn {
background: #333; color: #fff; font-size: 24rpx;
padding: 10rpx 24rpx; border-radius: 30rpx; font-weight: 600;
}
/* 道具卡 (Card Style) */
.item-cards-list { display: flex; flex-direction: column; gap: 24rpx; }
.item-card {
display: flex; background: #fff; border-radius: $radius-lg; padding: 24rpx;
box-shadow: $shadow-sm; align-items: center;
}
.ic-icon-wrap {
width: 100rpx; height: 100rpx; background: #f0f0f0;
border-radius: $radius-md; display: flex; align-items: center; justify-content: center;
font-size: 48rpx; margin-right: 24rpx;
}
.ic-info { flex: 1; display: flex; flex-direction: column; }
.ic-name { font-size: 30rpx; font-weight: 700; color: $text-main; margin-bottom: 4rpx; }
.ic-desc { font-size: 22rpx; color: $text-tertiary; margin-bottom: 20rpx; }
.ic-bottom { display: flex; align-items: center; justify-content: space-between; }
.ic-price .p-val { font-size: 32rpx; font-weight: 800; color: #FF9800; }
.ic-btn {
background: $gradient-brand; color: #fff; font-size: 24rpx;
padding: 8rpx 24rpx; border-radius: 30rpx;
}
.empty-state {
display: flex; flex-direction: column; align-items: center; padding-top: 100rpx;
}
.empty-img { width: 240rpx; opacity: 0.6; }
.empty-text { color: $text-tertiary; font-size: 26rpx; margin-top: 20rpx; }
.loading-wrap { padding: 100rpx 0; display: flex; justify-content: center; }
.spinner { .spinner {
width: 50rpx; width: 48rpx; height: 48rpx; border: 4rpx solid #eee; border-top-color: $brand-primary;
height: 50rpx; border-radius: 50%; animation: spin 0.8s linear infinite;
border: 4rpx solid rgba($brand-primary, 0.2);
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 100rpx;
}
.empty-img {
width: 240rpx;
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
.check-text {
font-size: 26rpx;
color: $text-sub;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style> </style>

808
pages/tasks/index.vue Normal file
View File

@ -0,0 +1,808 @@
<template>
<view class="page-container">
<!-- 顶部装饰背景 - 漂浮光球 -->
<view class="bg-decoration"></view>
<view class="header-area">
<view class="page-title">任务中心</view>
<view class="page-subtitle">Task Center</view>
</view>
<!-- 进度统计卡片 - 毛玻璃风格 -->
<view class="progress-card glass-card">
<view class="progress-header">
<text class="progress-title">📊 我的任务进度</text>
</view>
<view class="progress-stats">
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.orderCount || 0 }}</text>
<text class="stat-label">累计订单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value highlight">{{ userProgress.inviteCount || 0 }}</text>
<text class="stat-label">邀请人数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-value first-order-check" :class="{ done: userProgress.firstOrder }">
{{ userProgress.firstOrder ? '✓' : '—' }}
</view>
<text class="stat-label">首单完成</text>
</view>
</view>
</view>
<!-- 任务列表 -->
<scroll-view
scroll-y
class="content-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading && tasks.length === 0" class="loading-state">
<view class="spinner"></view>
<text>加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="tasks.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="task-list">
<view
v-for="(task, index) in tasks"
:key="task.id"
class="task-card"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 任务头部 -->
<view class="task-header" @click="toggleTask(task.id)">
<view class="task-info">
<text class="task-icon">{{ getTaskIcon(task) }}</text>
<view class="task-meta">
<text class="task-name">{{ task.name }}</text>
<text class="task-desc">{{ task.description }}</text>
</view>
</view>
<view class="task-status-wrap">
<view class="task-status" :class="getTaskStatusClass(task)">
{{ getTaskStatusText(task) }}
</view>
<text class="expand-arrow" :class="{ expanded: expandedTasks[task.id] }"></text>
</view>
</view>
<!-- 档位列表 (可展开) -->
<view class="tier-list" v-if="expandedTasks[task.id] && task.tiers && task.tiers.length > 0">
<view
v-for="tier in task.tiers"
:key="tier.id"
class="tier-item"
:class="{ 'tier-claimed': isTierClaimed(task.id, tier.id), 'tier-claimable': isTierClaimable(task, tier) }"
>
<view class="tier-left">
<view class="tier-condition">
<text class="tier-badge">{{ getTierBadge(tier) }}</text>
<text class="tier-text">{{ getTierConditionText(tier) }}</text>
</view>
<view class="tier-reward">
<text class="reward-icon">🎁</text>
<text class="reward-text">{{ getTierRewardText(task, tier) }}</text>
</view>
</view>
<view class="tier-right">
<!-- 已领取 -->
<view v-if="isTierClaimed(task.id, tier.id)" class="tier-btn claimed">
<text>已领取</text>
</view>
<!-- 可领取 -->
<view v-else-if="isTierClaimable(task, tier)" class="tier-btn claimable" @click="claimReward(task, tier)">
<text>{{ claiming[`${task.id}_${tier.id}`] ? '领取中...' : '领取' }}</text>
</view>
<!-- 进度中 -->
<view v-else class="tier-progress">
<text class="progress-text">{{ getTierProgressText(task, tier) }}</text>
</view>
</view>
</view>
</view>
<!-- 无档位提示 -->
<view class="no-tier-hint" v-if="expandedTasks[task.id] && (!task.tiers || task.tiers.length === 0)">
<text>暂无可领取档位</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getTasks, getTaskProgress, claimTaskReward } from '../../api/appUser'
const tasks = ref([])
const loading = ref(false)
const isRefreshing = ref(false)
const expandedTasks = reactive({})
const claiming = reactive({})
// ()
const userProgress = reactive({
orderCount: 0,
inviteCount: 0,
firstOrder: false,
claimedTiers: {} // { taskId: [tierId1, tierId2] }
})
// ID
function getUserId() {
return uni.getStorageSync('user_id')
}
//
function checkAuth() {
const token = uni.getStorageSync('token')
const userId = getUserId()
if (!token || !userId) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return false
}
return true
}
//
function getTaskIcon(task) {
const name = (task.name || '').toLowerCase()
if (name.includes('首单') || name.includes('first')) return '🎁'
if (name.includes('订单') || name.includes('order')) return '📦'
if (name.includes('邀请') || name.includes('invite')) return '👥'
if (name.includes('签到') || name.includes('check')) return '📅'
if (name.includes('分享') || name.includes('share')) return '📣'
return '⭐'
}
//
function getTaskStatusClass(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return 'status-waiting'
//
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return 'status-done'
//
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return 'status-claimable'
}
return 'status-progress'
}
//
function getTaskStatusText(task) {
const progress = userProgress.claimedTiers[task.id] || []
const allTiers = task.tiers || []
if (allTiers.length === 0) return '暂无档位'
const allClaimed = allTiers.every(t => progress.includes(t.id))
if (allClaimed) return '已完成'
if (allTiers.some(t => isTierClaimable(task, t) && !progress.includes(t.id))) {
return '可领取'
}
return '进行中'
}
// /
function toggleTask(taskId) {
expandedTasks[taskId] = !expandedTasks[taskId]
}
//
function getTierBadge(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '首'
if (metric === 'order_count') return `${tier.threshold}`
if (metric === 'invite_count') return `${tier.threshold}`
return tier.threshold || ''
}
//
function getTierConditionText(tier) {
const metric = tier.metric || ''
if (metric === 'first_order') return '完成首笔订单'
if (metric === 'order_count') return `累计下单 ${tier.threshold}`
if (metric === 'invite_count') return `邀请 ${tier.threshold} 位好友`
return `达成 ${tier.threshold}`
}
//
function getTierRewardText(task, tier) {
const rewards = (task.rewards || []).filter(r => r.tier_id === tier.id)
if (rewards.length === 0) return '神秘奖励'
const texts = rewards.map(r => {
const type = r.reward_type || ''
const payload = r.reward_payload || {}
const qty = r.quantity || 1
if (type === 'points') {
const value = payload.value || payload.amount || qty
return `${value}积分`
}
if (type === 'coupon') {
const value = payload.value || payload.amount
return value ? `¥${value / 100}优惠券` : '优惠券'
}
if (type === 'item_card') {
const name = payload.name || '道具卡'
return qty > 1 ? `${name}×${qty}` : name
}
if (type === 'title') {
return payload.name || '专属称号'
}
return '奖励'
})
return texts.join(' + ')
}
//
function isTierClaimed(taskId, tierId) {
const claimed = userProgress.claimedTiers[taskId] || []
return claimed.includes(tierId)
}
//
function isTierClaimable(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
const operator = tier.operator || '>='
let current = 0
if (metric === 'first_order') {
return userProgress.firstOrder
} else if (metric === 'order_count') {
current = userProgress.orderCount || 0
} else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0
}
if (operator === '>=') return current >= threshold
if (operator === '==') return current === threshold
if (operator === '>') return current > threshold
return current >= threshold
}
//
function getTierProgressText(task, tier) {
const metric = tier.metric || ''
const threshold = tier.threshold || 0
let current = 0
if (metric === 'first_order') {
return userProgress.firstOrder ? '已完成' : '未完成'
} else if (metric === 'order_count') {
current = userProgress.orderCount || 0
} else if (metric === 'invite_count') {
current = userProgress.inviteCount || 0
}
return `${current}/${threshold}`
}
//
async function claimReward(task, tier) {
const key = `${task.id}_${tier.id}`
if (claiming[key]) return
uni.vibrateShort({ type: 'medium' })
claiming[key] = true
try {
const userId = getUserId()
await claimTaskReward(task.id, userId, tier.id)
//
if (!userProgress.claimedTiers[task.id]) {
userProgress.claimedTiers[task.id] = []
}
userProgress.claimedTiers[task.id].push(tier.id)
uni.showToast({ title: '领取成功!', icon: 'success' })
} catch (e) {
console.error('领取失败:', e)
uni.showToast({ title: e.message || '领取失败', icon: 'none' })
} finally {
claiming[key] = false
}
}
//
async function onRefresh() {
isRefreshing.value = true
await fetchData()
isRefreshing.value = false
}
//
async function fetchData() {
if (!checkAuth()) return
loading.value = true
try {
const userId = getUserId()
//
const res = await getTasks(1, 50)
const list = res.list || res.data || []
tasks.value = list
//
if (list.length > 0 && Object.keys(expandedTasks).length === 0) {
expandedTasks[list[0].id] = true
}
// ()
if (list.length > 0) {
try {
const progressRes = await getTaskProgress(list[0].id, userId)
userProgress.orderCount = progressRes.order_count || 0
userProgress.inviteCount = progressRes.invite_count || 0
userProgress.firstOrder = progressRes.first_order || false
//
if (progressRes.claimed_tiers) {
userProgress.claimedTiers[list[0].id] = progressRes.claimed_tiers
}
} catch (e) {
console.error('获取进度失败:', e)
}
//
const otherTasks = list.slice(1)
const progressPromises = otherTasks.map(t =>
getTaskProgress(t.id, userId).catch(() => null)
)
const progressResults = await Promise.allSettled(progressPromises)
progressResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const taskId = otherTasks[index].id
userProgress.claimedTiers[taskId] = result.value.claimed_tiers || []
}
})
}
} catch (e) {
console.error('获取任务失败:', e)
} finally {
loading.value = false
}
}
onLoad(() => {
fetchData()
})
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: $bg-page;
position: relative;
overflow: hidden;
}
.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;
}
/* 进度统计卡片 */
.progress-card {
@extend .glass-card;
margin: 0 $spacing-lg $spacing-lg;
padding: 30rpx;
}
.progress-header {
margin-bottom: 24rpx;
}
.progress-title {
font-size: 26rpx;
font-weight: 700;
color: $text-sub;
}
.progress-stats {
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: 900;
color: $text-main;
font-family: 'DIN Alternate', sans-serif;
line-height: 1.2;
}
.stat-value.highlight {
color: $brand-primary;
}
.stat-value.first-order-check {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
color: $text-tertiary;
&.done {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
}
.stat-label {
font-size: 22rpx;
color: $text-tertiary;
margin-top: 8rpx;
font-weight: 500;
}
.stat-divider {
width: 1px;
height: 50rpx;
background: $border-color-light;
}
/* 内容滚动区 */
.content-scroll {
height: calc(100vh - 400rpx);
padding: 0 $spacing-lg $spacing-lg;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: $text-tertiary;
font-size: 26rpx;
gap: 16rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.empty-hint {
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
/* 任务列表 */
.task-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 任务卡片 */
.task-card {
background: #fff;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-sm;
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-header {
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
&:active {
background: rgba(0, 0, 0, 0.02);
}
}
.task-info {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.task-icon {
font-size: 40rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.task-meta {
flex: 1;
overflow: hidden;
}
.task-name {
font-size: 30rpx;
font-weight: 700;
color: $text-main;
display: block;
margin-bottom: 4rpx;
}
.task-desc {
font-size: 24rpx;
color: $text-sub;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 16rpx;
}
.task-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 100rpx;
margin-right: 8rpx;
&.status-done {
background: rgba($uni-color-success, 0.1);
color: $uni-color-success;
}
&.status-claimable {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
font-weight: 700;
}
&.status-progress {
background: rgba($brand-primary, 0.05);
color: $text-sub;
}
&.status-waiting {
background: #f5f5f5;
color: $text-tertiary;
}
}
.expand-arrow {
font-size: 28rpx;
color: $text-tertiary;
transition: transform 0.3s;
&.expanded {
transform: rotate(90deg);
}
}
/* 档位列表 */
.tier-list {
border-top: 1rpx solid $border-color-light;
padding: 16rpx 24rpx 24rpx;
background: #fafafa;
}
.tier-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 20rpx;
background: #fff;
border-radius: $radius-md;
margin-bottom: 12rpx;
border: 1rpx solid $border-color-light;
&:last-child {
margin-bottom: 0;
}
&.tier-claimed {
background: #f5f5f5;
opacity: 0.7;
}
&.tier-claimable {
border-color: $brand-primary;
background: rgba($brand-primary, 0.02);
}
}
.tier-left {
flex: 1;
overflow: hidden;
}
.tier-condition {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.tier-badge {
background: $text-main;
color: #fff;
font-size: 18rpx;
padding: 4rpx 10rpx;
border-radius: 6rpx;
margin-right: 12rpx;
font-weight: 700;
}
.tier-text {
font-size: 26rpx;
color: $text-main;
font-weight: 500;
}
.tier-reward {
display: flex;
align-items: center;
}
.reward-icon {
font-size: 20rpx;
margin-right: 6rpx;
}
.reward-text {
font-size: 22rpx;
color: $brand-primary;
}
.tier-right {
flex-shrink: 0;
margin-left: 16rpx;
}
.tier-btn {
padding: 10rpx 24rpx;
border-radius: 100rpx;
font-size: 24rpx;
font-weight: 600;
&.claimed {
background: #eee;
color: $text-tertiary;
}
&.claimable {
background: $brand-primary;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.3);
&:active {
transform: scale(0.95);
}
}
}
.tier-progress {
padding: 10rpx 16rpx;
}
.progress-text {
font-size: 24rpx;
color: $text-sub;
font-family: 'DIN Alternate', sans-serif;
}
.no-tier-hint {
padding: 30rpx;
text-align: center;
color: $text-tertiary;
font-size: 24rpx;
background: #fafafa;
border-top: 1rpx solid $border-color-light;
}
/* 加载动画 */
.spinner {
width: 28rpx;
height: 28rpx;
border: 3rpx solid $bg-secondary;
border-top-color: $text-tertiary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -182,4 +182,88 @@ $uni-font-size-paragraph: 15px;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: $lines; -webkit-line-clamp: $lines;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
}
/* ============================================
💎 核心公共 UI (Premium UI 6.0)
============================================ */
/* 1. 统一背景装饰 - 漂浮光球 */
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -100rpx; right: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.15) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.8;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
top: 200rpx; left: -200rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 15s ease-in-out infinite reverse;
}
}
/* 2. 毛玻璃卡片基类 */
.glass-card {
background: $bg-glass;
backdrop-filter: blur(20rpx);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: $shadow-card;
border-radius: $radius-lg;
position: relative;
z-index: 1;
}
/* 3. 通用功能按钮 */
.btn-primary {
background: $gradient-brand;
color: #fff;
font-weight: 700;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-warm;
transition: all 0.2s $ease-out;
&:active {
transform: scale(0.96);
opacity: 0.9;
}
}
.btn-secondary {
background: rgba(255, 255, 255, 0.9);
color: $text-main;
font-weight: 600;
border-radius: $radius-round;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-sm;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s $ease-out;
&:active {
transform: scale(0.96);
background: #fff;
}
} }

View File

@ -94,10 +94,13 @@ export function normalizeRewards(list, cleanUrl = (u) => u) {
level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏')) level: levelToAlpha(i.prize_level ?? i.level ?? (detectBoss(i) ? 'BOSS' : '赏'))
})) }))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0) const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({ const enriched = items.map(it => {
...it, const rawPercent = total > 0 ? (it.weight / total) * 100 : 0
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0 return {
})) ...it,
percent: parseFloat(rawPercent.toFixed(2)) // 统一保留2位小数
}
})
enriched.sort((a, b) => (b.percent - a.percent)) enriched.sort((a, b) => (b.percent - a.percent))
return enriched return enriched
} }
@ -143,7 +146,7 @@ export function groupRewardsByLevel(rewards) {
return { return {
level: key, level: key,
rewards: levelRewards, rewards: levelRewards,
totalPercent: total.toFixed(1) totalPercent: total.toFixed(2) // 统一保留2位小数
} }
}) })
} }

View File

@ -2,7 +2,7 @@
* 缓存管理工具 * 缓存管理工具
*/ */
const REWARD_CACHE_KEY = 'reward_cache_v1' const REWARD_CACHE_KEY = 'reward_cache_v2' // v2: 修复概率精度问题
const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1' const MATCHING_GAME_CACHE_KEY = 'matching_game_cache_v1'
/** /**

View File

@ -22,7 +22,7 @@ function handleAuthExpired() {
export function request({ url, method = 'GET', data = {}, header = {} }) { export function request({ url, method = 'GET', data = {}, header = {} }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const finalHeader = { ...buildDefaultHeaders(), ...header } const finalHeader = { ...buildDefaultHeaders(), ...header }
try { console.log('HTTP request', method, url, 'data', data, 'headers', finalHeader) } catch (e) {} try { console.log('HTTP request', method, url, 'data', data, 'headers', finalHeader) } catch (e) { }
uni.request({ uni.request({
url: BASE_URL + url, url: BASE_URL + url,
method, method,
@ -31,7 +31,7 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
timeout: 15000, timeout: 15000,
success: (res) => { success: (res) => {
const code = res.statusCode const code = res.statusCode
try { console.log('HTTP response', method, url, 'status', code, 'body', res.data) } catch (e) {} try { console.log('HTTP response', method, url, 'status', code, 'body', res.data) } catch (e) { }
if (code >= 200 && code < 300) { if (code >= 200 && code < 300) {
const body = res.data const body = res.data
resolve(body && body.data !== undefined ? body.data : body) resolve(body && body.data !== undefined ? body.data : body)
@ -47,7 +47,7 @@ export function request({ url, method = 'GET', data = {}, header = {} }) {
} }
}, },
fail: (err) => { fail: (err) => {
try { console.error('HTTP fail', method, url, 'err', err) } catch (e) {} try { console.error('HTTP fail', method, url, 'err', err) } catch (e) { }
reject(err) reject(err)
} }
}) })
@ -66,11 +66,11 @@ export function authRequest(options) {
return request({ ...options, header }) return request({ ...options, header })
} }
export function redeemProductByPoints(user_id, product_id, count) { export function redeemProductByPoints(user_id, product_id, quantity) {
return authRequest({ return authRequest({
url: '/api/app/users/points/redeem-product', url: `/api/app/users/${user_id}/points/redeem-product`,
method: 'POST', method: 'POST',
data: { user_id, product_id, count } data: { product_id, quantity }
}) })
} }