合并页面

This commit is contained in:
ty200947752 2025-12-03 10:48:20 +08:00
parent 0c4ed4360f
commit 8f044d68ca
11 changed files with 877 additions and 155 deletions

View File

@ -2,6 +2,7 @@
export default {
onLaunch: function() {
console.log('App Launch')
try { uni.setStorageSync('app_session_id', String(Date.now())) } catch (_) {}
},
onShow: function() {
console.log('App Show')

View File

@ -58,3 +58,11 @@ export function getActivityIssues(activity_id) {
export function getActivityIssueRewards(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/rewards`, method: 'GET' })
}
export function drawActivityIssue(activity_id, issue_id) {
return authRequest({ url: `/api/app/activities/${activity_id}/issues/${issue_id}/draw`, method: 'POST' })
}
export function getActivityWinRecords(activity_id, page = 1, page_size = 20) {
return authRequest({ url: `/api/app/activities/${activity_id}/wins`, method: 'GET', data: { page, page_size } })
}

30
components/ElCard.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<view class="ep-card" :class="shadowClass">
<view v-if="$slots.header" class="ep-card__header">
<slot name="header" />
</view>
<view class="ep-card__body">
<slot />
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ shadow: { type: String, default: 'always' } })
const shadowClass = computed(() => {
const s = String(props.shadow || '').toLowerCase()
if (s === 'hover') return 'is-hover-shadow'
if (s === 'never') return 'is-no-shadow'
return 'is-always-shadow'
})
</script>
<style scoped>
.ep-card { background: #fff; border-radius: 12rpx; overflow: hidden; border: 1rpx solid #eee }
.is-always-shadow { box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06) }
.is-hover-shadow { box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06) }
.is-no-shadow { box-shadow: none }
.ep-card__header { padding: 12rpx 16rpx; border-bottom: 1rpx solid #f0f0f0 }
.ep-card__body { padding: 12rpx 16rpx }
</style>

83
components/FlipGrid.vue Normal file
View File

@ -0,0 +1,83 @@
<template>
<view class="flip-root">
<view v-if="controls" class="flip-actions">
<button class="flip-btn" @tap="onDraw(1)">单次抽选</button>
<button class="flip-btn" @tap="onDraw(10)">十次抽选</button>
</view>
<view class="flip-grid">
<view v-for="(cell, i) in cells" :key="i" class="flip-card" :class="{ flipped: cell.flipped }">
<view class="flip-inner">
<view class="flip-front">
<view class="front-placeholder"></view>
</view>
<view class="flip-back" @tap="onPreview(cell)">
<image v-if="cell.image" class="flip-image" :src="cell.image" mode="widthFix" />
<text class="flip-title">{{ cell.title || '' }}</text>
</view>
</view>
</view>
</view>
<view v-if="controls" class="flip-toolbar">
<button class="flip-reset" @tap="reset">重置</button>
</view>
</view>
</template>
<script setup>
import { ref, defineExpose } from 'vue'
const props = defineProps({ rewards: { type: Array, default: () => [] }, controls: { type: Boolean, default: true } })
const emit = defineEmits(['draw'])
const total = 16
const cells = ref(Array(total).fill(0).map(() => ({ flipped: false, title: '', image: '' })))
function onDraw(count) { emit('draw', count) }
function revealResults(list) {
const arr = Array.isArray(list) ? list : list ? [list] : []
const toFill = Math.min(arr.length, total)
const indices = Array(total).fill(0).map((_, i) => i)
for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = indices[i]; indices[i] = indices[j]; indices[j] = tmp }
const chosen = indices.slice(0, toFill)
const res = arr.slice(0, toFill)
for (let i = res.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = res[i]; res[i] = res[j]; res[j] = t }
chosen.forEach((pos, i) => {
const it = res[i] || {}
const title = String(it.title || it.name || '')
const image = String(it.image || it.img || it.pic || '')
cells.value[pos] = { flipped: false, title, image }
const delay = 100 * i + Math.floor(Math.random() * 120)
setTimeout(() => { cells.value[pos].flipped = true }, delay)
})
}
function reset() {
cells.value = Array(total).fill(0).map(() => ({ flipped: false, title: '', image: '' }))
}
function onPreview(cell) {
const img = String(cell && cell.image || '')
if (img) uni.previewImage({ urls: [img], current: img })
}
defineExpose({ revealResults, reset })
</script>
<style scoped>
.flip-root { display: flex; flex-direction: column; gap: 16rpx; padding: 16rpx }
.flip-actions { display: flex; gap: 12rpx }
.flip-btn { flex: 1; background: #007AFF; color: #fff; border-radius: 8rpx }
.flip-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12rpx }
.flip-card { perspective: 1000px }
.flip-inner { position: relative; width: 100%; height: 200rpx; transform-style: preserve-3d; transition: transform 0.5s }
.flip-card.flipped .flip-inner { transform: rotateY(180deg) }
.flip-front, .flip-back { position: absolute; width: 100%; height: 100%; backface-visibility: hidden; border-radius: 12rpx; overflow: hidden }
.flip-front { background: #e2e8f0; display: flex; align-items: center; justify-content: center }
.front-placeholder { width: 80%; height: 80%; border-radius: 12rpx; background: linear-gradient(135deg, #f8fafc, #e2e8f0) }
.flip-back { background: #fff; transform: rotateY(180deg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 12rpx }
.flip-image { width: 80%; border-radius: 8rpx; margin-bottom: 8rpx; background: #f5f5f5 }
.flip-title { font-size: 26rpx; color: #222; text-align: center; max-width: 90%; word-break: break-all }
.flip-toolbar { display: flex; justify-content: flex-end }
.flip-reset { background: #ffd166; color: #6b4b1f; border-radius: 999rpx }
</style>

View File

@ -5,46 +5,83 @@
</view>
<view class="header">
<view class="title">{{ detail.name || detail.title || '-' }}</view>
<view class="meta">分类{{ detail.category_name || '对对碰' }}</view>
<view class="meta" v-if="detail.price_draw !== undefined">参与价{{ detail.price_draw }}</view>
<view class="meta" v-if="detail.status !== undefined">状态{{ statusText }}</view>
</view>
<view class="actions">
<button class="btn" @click="onPreviewBanner">查看图片</button>
<button class="btn primary" @click="onParticipate">立即参与</button>
</view>
<view class="issues">
<view class="issues" v-if="showIssues">
<view class="issues-title">期数</view>
<view v-if="issues.length" class="issues-list">
<view class="issue-item" v-for="it in issues" :key="it.id">
<text class="issue-title">{{ it.title || ('第' + (it.no || it.index || it.issue_no || '-') + '期') }}</text>
<text class="issue-status" v-if="it.status_text">{{ it.status_text }}</text>
<view class="rewards" v-if="rewardsMap[it.id] && rewardsMap[it.id].length">
<view class="reward" v-for="rw in rewardsMap[it.id]" :key="rw.id">
<image v-if="rw.image" class="reward-img" :src="rw.image" mode="aspectFill" />
<view class="reward-texts">
<text class="reward-title">{{ rw.title }}</text>
<text class="reward-meta" v-if="rw.rarity || rw.odds">{{ [rw.rarity, rw.odds].filter(Boolean).join(' · ') }}</text>
<picker-view class="issue-picker" :value="[selectedIssueIndex]" @change="onIssueChange">
<picker-view-column>
<view class="picker-item" v-for="it in issues" :key="it.id">{{ it.title || ('' + (it.no || it.index || it.issue_no || '-') + '') }}</view>
</picker-view-column>
</picker-view>
<view class="tabs">
<view class="tab" :class="{ active: tabActive === 'pool' }" @click="tabActive = 'pool'">本机奖池</view>
<view class="tab" :class="{ active: tabActive === 'records' }" @click="tabActive = 'records'">中奖记录</view>
</view>
<view v-show="tabActive === 'pool'">
<view class="rewards" v-if="currentIssueId && rewardsMap[currentIssueId] && rewardsMap[currentIssueId].length">
<el-card v-for="rw in rewardsMap[currentIssueId]" :key="rw.id" class="el-reward-card" shadow="hover">
<template #header>
<view class="el-card-header">
<text class="el-card-title">{{ rw.title }}</text>
<text v-if="rw.boss" class="badge-boss">BOSS</text>
</view>
</template>
<view class="card-image-wrap">
<image v-if="rw.image" class="card-image" :src="rw.image" mode="widthFix" />
<text class="prob-corner tl">概率 {{ rw.percent }}%</text>
</view>
</view>
</el-card>
</view>
<view class="rewards-empty" v-else>暂无奖励配置</view>
</view>
<view v-show="tabActive === 'records'">
<view class="records" v-if="winRecords.length">
<el-card v-for="it in winRecords" :key="it.id" class="el-reward-card" shadow="hover">
<template #header>
<view class="el-card-header">
<text class="el-card-title">{{ it.title }}</text>
<text v-if="it.count !== undefined" class="badge-count">x{{ it.count }}</text>
</view>
</template>
<view class="card-image-wrap">
<image v-if="it.image" class="card-image" :src="it.image" mode="widthFix" />
<text v-if="it.percent !== undefined" class="prob-corner tl">占比 {{ it.percent }}%</text>
</view>
</el-card>
</view>
<view class="rewards-empty" v-else>暂无中奖记录</view>
</view>
</view>
<view v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view class="float-actions">
<button class="float-btn primary" @click="onParticipate">立即参与</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, getCurrentInstance } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards } from '../../../api/appUser'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, drawActivityIssue, getActivityWinRecords } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const tabActive = ref('pool')
const winRecords = ref([])
function statusToText(s) {
if (s === 1) return '进行中'
@ -67,6 +104,23 @@ function unwrap(list) {
return Array.isArray(arr) ? arr : []
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeIssues(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
@ -78,32 +132,127 @@ function normalizeIssues(list) {
}
function normalizeRewards(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
image: i.image ?? i.img ?? i.pic ?? i.banner ?? '',
odds: i.odds ?? i.rate ?? i.probability ?? i.prob ?? '',
rarity: i.rarity ?? i.rarity_name ?? ''
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const promises = list.map(it => getActivityIssueRewards(activityId, it.id))
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = list[i] && list[i].id
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
const v = e && e.detail && e.detail.value
const idx = Array.isArray(v) ? (v[0] || 0) : 0
const arr = issues.value || []
const bounded = Math.min(Math.max(0, idx), arr.length - 1)
selectedIssueIndex.value = bounded
const cur = arr[bounded]
currentIssueId.value = (cur && cur.id) || ''
}
async function ensureElCard() {
const inst = getCurrentInstance()
const app = inst && inst.appContext && inst.appContext.app
let comp = null
if (typeof window !== 'undefined' && window.ElementPlus && window.ElementPlus.ElCard) {
comp = window.ElementPlus.ElCard
} else {
comp = ElCard
}
if (app && !app.component('el-card')) app.component('el-card', comp)
}
@ -113,41 +262,70 @@ function onPreviewBanner() {
}
function onParticipate() {
uni.showToast({ title: '功能待接入', icon: 'none' })
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
uni.showLoading({ title: '抽选中...' })
drawActivityIssue(aid, iid).then(res => {
try { uni.hideLoading() } catch (_) {}
const obj = res || {}
const data = obj.data || obj.result || obj.reward || obj.item || obj
const name = String((data && (data.title || data.name || data.product_name)) || '未知奖励')
const img = String((data && (data.image || data.img || data.pic || data.product_image)) || '')
uni.showModal({ title: '抽选结果', content: '恭喜获得:' + name, showCancel: false, success: () => { if (img) uni.previewImage({ urls: [img], current: img }) } })
}).catch(err => {
try { uni.hideLoading() } catch (_) {}
const msg = String((err && (err.message || err.msg)) || '抽选失败')
uni.showToast({ title: msg, icon: 'none' })
})
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
ensureElCard()
})
</script>
<style scoped>
.page { height: 100vh }
.page { height: 100vh; padding-bottom: 140rpx }
.banner { padding: 24rpx }
.banner-img { width: 100% }
.header { padding: 0 24rpx }
.title { font-size: 36rpx; font-weight: 700 }
.title { font-size: 36rpx; font-weight: 700; color: #DD2C00; text-align: center }
.meta { margin-top: 8rpx; font-size: 26rpx; color: #666 }
.actions { display: flex; padding: 24rpx; gap: 16rpx }
.btn { flex: 1 }
.primary { background-color: #007AFF; color: #fff }
.float-actions { position: fixed; left: 0; right: 0; bottom: 0; padding: 16rpx 24rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: rgba(255,255,255,0.9); box-shadow: 0 -6rpx 16rpx rgba(0,0,0,0.06); z-index: 9999 }
.float-btn { width: 100%; border-radius: 999rpx }
.issues { background: #fff; border-radius: 12rpx; margin: 0 24rpx 24rpx; padding: 16rpx }
.issues-title { font-size: 30rpx; font-weight: 600; margin-bottom: 12rpx }
.issues-list { }
.issue-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f0f0f0 }
.issue-item:last-child { border-bottom: 0 }
.issue-title { font-size: 26rpx }
.issue-status { font-size: 24rpx; color: #666 }
.rewards { width: 100%; margin-top: 12rpx }
.reward { display: flex; align-items: center; margin-bottom: 8rpx }
.reward-img { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin-right: 12rpx; background: #f5f5f5 }
.reward-texts { display: flex; flex-direction: column }
.reward-title { font-size: 26rpx }
.reward-meta { font-size: 22rpx; color: #888 }
.issue-picker { height: 200rpx; background: #f8f8f8; border-radius: 12rpx; margin-bottom: 64rpx }
.picker-item { height: 40rpx; line-height: 40rpx; text-align: center; font-size: 26rpx }
.tabs { display: flex; padding: 0 12rpx; margin-bottom: 16rpx }
.tab { flex: 1; text-align: center; font-size: 28rpx; padding: 16rpx 0; border: 2rpx solid #f0c58a; color: #8a5a2b; background: #fff3df; border-radius: 16rpx }
.tab + .tab { margin-left: 12rpx }
.tab.active { background: #ffdfaa; border-color: #ffb74d; color: #6b4b1f; font-weight: 600 }
.rewards { width: 100%; margin-top: 24rpx }
.reward-card { background: #fff; border-radius: 12rpx; overflow: hidden; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); margin-bottom: 12rpx }
.el-reward-card { margin-bottom: 12rpx }
.el-card-header { display: flex; align-items: center; justify-content: space-between }
.el-card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.card-image-wrap { position: relative; padding-bottom: 48rpx }
.card-image { width: 100%; height: auto; display: block; background: #f0f4ff; position: relative; z-index: 1 }
.prob-corner { position: absolute; background: rgba(221,82,77,0.9); color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx; z-index: 2 }
.prob-corner.tl { top: 12rpx; left: 12rpx }
.card-body { display: flex; align-items: center; justify-content: space-between; padding: 12rpx }
.card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.badge-boss { background: #ff9f0a; color: #222; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.badge-count { background: #ffd166; color: #6b4b1f; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.rewards-empty { font-size: 24rpx; color: #999 }
.issues-empty { font-size: 24rpx; color: #999 }
</style>

View File

@ -5,46 +5,57 @@
</view>
<view class="header">
<view class="title">{{ detail.name || detail.title || '-' }}</view>
<view class="meta">分类{{ detail.category_name || '无限赏' }}</view>
<view class="meta" v-if="detail.price_draw !== undefined">单次抽选{{ detail.price_draw }}</view>
<view class="meta" v-if="detail.status !== undefined">状态{{ statusText }}</view>
</view>
<view class="actions">
<button class="btn" @click="onPreviewBanner">查看图片</button>
<button class="btn primary" @click="onParticipate">立即参与</button>
<view class="draw-actions">
<button class="draw-btn" @click="() => onMachineDraw(1)">单次抽选</button>
<button class="draw-btn" @click="() => onMachineDraw(10)">十次抽选</button>
<button class="draw-btn secondary" @click="onMachineTry">试一试</button>
</view>
<view class="issues">
<view class="issues-title">期数</view>
<view v-if="issues.length" class="issues-list">
<view class="issue-item" v-for="it in issues" :key="it.id">
<text class="issue-title">{{ it.title || ('第' + (it.no || it.index || it.issue_no || '-') + '期') }}</text>
<text class="issue-status" v-if="it.status_text">{{ it.status_text }}</text>
<view class="rewards" v-if="rewardsMap[it.id] && rewardsMap[it.id].length">
<view class="reward" v-for="rw in rewardsMap[it.id]" :key="rw.id">
<image v-if="rw.image" class="reward-img" :src="rw.image" mode="aspectFill" />
<view class="reward-texts">
<text class="reward-title">{{ rw.title }}</text>
<text class="reward-meta" v-if="rw.rarity || rw.odds">{{ [rw.rarity, rw.odds].filter(Boolean).join(' · ') }}</text>
</view>
</view>
</view>
<view class="rewards-empty" v-else>暂无奖励配置</view>
</view>
<view class="issues" v-if="showIssues && issues.length">
<view class="issue-switch">
<button class="switch-btn" @click="prevIssue"></button>
<text class="issue-title">{{ currentIssueTitle }}</text>
<button class="switch-btn" @click="nextIssue"></button>
</view>
<view v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, getCurrentInstance } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import FlipGrid from '../../../components/FlipGrid.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards } from '../../../api/appUser'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, drawActivityIssue } from '../../../api/appUser'
const detail = ref({})
const statusText = ref('')
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const drawLoading = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
function statusToText(s) {
if (s === 1) return '进行中'
@ -76,76 +87,220 @@ function normalizeIssues(list) {
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
image: i.image ?? i.img ?? i.pic ?? i.banner ?? '',
odds: i.odds ?? i.rate ?? i.probability ?? i.prob ?? '',
rarity: i.rarity ?? i.rarity_name ?? ''
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const promises = list.map(it => getActivityIssueRewards(activityId, it.id))
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = list[i] && list[i].id
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
async function ensureElCard() {
const inst = getCurrentInstance()
const app = inst && inst.appContext && inst.appContext.app
let comp = null
if (typeof window !== 'undefined' && window.ElementPlus && window.ElementPlus.ElCard) {
comp = window.ElementPlus.ElCard
} else {
comp = ElCard
}
if (app && !app.component('el-card')) app.component('el-card', comp)
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function onParticipate() {
uni.showToast({ title: '功能待接入', icon: 'none' })
function onMachineDraw(count) {
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
drawLoading.value = true
const times = Math.max(1, Number(count || 1))
const calls = Array(times).fill(0).map(() => drawActivityIssue(aid, iid))
Promise.allSettled(calls).then(list => {
drawLoading.value = false
const items = list.map(r => {
const obj = r.status === 'fulfilled' ? r.value : {}
const data = obj && (obj.data || obj.result || obj.reward || obj.item || obj)
const title = String((data && (data.title || data.name || data.product_name)) || '未知奖励')
const image = String((data && (data.image || data.img || data.pic || data.product_image)) || '')
return { title, image }
})
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
}).catch(() => { drawLoading.value = false; if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults([{ title: '抽选失败', image: '' }]) })
}
function onMachineTry() {
const list = rewardsMap.value[currentIssueId.value] || []
if (!list.length) { uni.showToast({ title: '暂无奖池', icon: 'none' }); return }
const idx = Math.floor(Math.random() * list.length)
const it = list[idx]
uni.showModal({ title: '试一试', content: it.title || '随机预览', showCancel: false, success: () => { if (it.image) uni.previewImage({ urls: [it.image], current: it.image }) } })
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
}
ensureElCard()
})
function closeFlip() { showFlip.value = false }
</script>
<style scoped>
.page { height: 100vh }
.page { height: 100vh; padding-bottom: 140rpx }
.banner { padding: 24rpx }
.banner-img { width: 100% }
.header { padding: 0 24rpx }
.title { font-size: 36rpx; font-weight: 700 }
.title { font-size: 36rpx; font-weight: 700; color: #DD2C00; text-align: center }
.meta { margin-top: 8rpx; font-size: 26rpx; color: #666 }
.actions { display: flex; padding: 24rpx; gap: 16rpx }
.btn { flex: 1 }
.primary { background-color: #007AFF; color: #fff }
.draw-actions { display: flex; gap: 12rpx; padding: 24rpx }
.draw-btn { flex: 1; background: #007AFF; color: #fff; border-radius: 8rpx }
.draw-btn.secondary { background: #ffd166; color: #6b4b1f }
.flip-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 10000 }
.flip-mask { position: absolute; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1 }
.flip-content { position: relative; display: flex; flex-direction: column; height: 100%; padding: 24rpx; z-index: 2 }
.overlay-close { background: #ffd166; color: #6b4b1f; border-radius: 999rpx; align-self: flex-end }
.issues { background: #fff; border-radius: 12rpx; margin: 0 24rpx 24rpx; padding: 16rpx }
.issues-title { font-size: 30rpx; font-weight: 600; margin-bottom: 12rpx }
.issues-list { }
.issue-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f0f0f0 }
.issue-item:last-child { border-bottom: 0 }
.issue-title { font-size: 26rpx }
.issue-status { font-size: 24rpx; color: #666 }
.rewards { width: 100%; margin-top: 12rpx }
.issue-switch { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin: 0 24rpx 24rpx }
.switch-btn { width: 72rpx; height: 72rpx; border-radius: 999rpx; background: #fff3df; border: 2rpx solid #f0c58a; color: #8a5a2b }
.issue-title { font-size: 28rpx; color: #6b4b1f; background: #ffdfaa; border-radius: 12rpx; padding: 8rpx 16rpx }
.rewards { width: 100%; margin-top: 24rpx }
.reward { display: flex; align-items: center; margin-bottom: 8rpx }
.reward-img { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin-right: 12rpx; background: #f5f5f5 }
.reward-texts { display: flex; flex-direction: column }
.reward-title { font-size: 26rpx }
.reward-meta { font-size: 22rpx; color: #888 }
.reward-card { background: #fff; border-radius: 12rpx; overflow: hidden; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); margin-bottom: 12rpx }
.el-reward-card { margin-bottom: 12rpx }
.el-card-header { display: flex; align-items: center; justify-content: space-between }
.el-card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.card-image-wrap { position: relative; padding-bottom: 48rpx }
.card-image { width: 100%; height: auto; display: block; background: #f0f4ff; position: relative; z-index: 1 }
.prob-corner { position: absolute; background: rgba(221,82,77,0.9); color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx; z-index: 2 }
.prob-corner.tl { top: 12rpx; left: 12rpx }
.card-body { display: flex; align-items: center; justify-content: space-between; padding: 12rpx }
.card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.badge-boss { background: #ff9f0a; color: #222; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.rewards-empty { font-size: 24rpx; color: #999 }
.issues-empty { font-size: 24rpx; color: #999 }
</style>

View File

@ -5,45 +5,58 @@
</view>
<view class="header">
<view class="title">{{ detail.name || detail.title || '-' }}</view>
<view class="meta">分类{{ detail.category_name || '一番赏' }}</view>
<view class="meta" v-if="detail.price_draw !== undefined">抽选价{{ detail.price_draw }}</view>
<view class="meta" v-if="detail.status !== undefined">状态{{ statusText }}</view>
</view>
<view class="actions">
<button class="btn" @click="onPreviewBanner">查看图片</button>
<button class="btn primary" @click="onParticipate">立即参与</button>
<view class="draw-actions">
<button class="draw-btn" @click="() => onMachineDraw(1)">单次抽选</button>
<button class="draw-btn" @click="() => onMachineDraw(10)">十次抽选</button>
<button class="draw-btn secondary" @click="onMachineTry">试一试</button>
</view>
<view class="issues">
<view class="issues-title">期数</view>
<view v-if="issues.length" class="issues-list">
<view class="issue-item" v-for="it in issues" :key="it.id">
<text class="issue-title">{{ it.title || ('第' + (it.no || it.index || it.issue_no || '-') + '期') }}</text>
<text class="issue-status" v-if="it.status_text">{{ it.status_text }}</text>
<view class="rewards" v-if="rewardsMap[it.id] && rewardsMap[it.id].length">
<view class="reward" v-for="rw in rewardsMap[it.id]" :key="rw.id">
<image v-if="rw.image" class="reward-img" :src="rw.image" mode="aspectFill" />
<view class="reward-texts">
<text class="reward-title">{{ rw.title }}</text>
<text class="reward-meta" v-if="rw.rarity || rw.odds">{{ [rw.rarity, rw.odds].filter(Boolean).join(' · ') }}</text>
</view>
</view>
</view>
<view class="rewards-empty" v-else>暂无奖励配置</view>
</view>
<view class="issues" v-if="showIssues && issues.length">
<view class="issue-switch">
<button class="switch-btn" @click="prevIssue"></button>
<text class="issue-title">{{ currentIssueTitle }}</text>
<button class="switch-btn" @click="nextIssue"></button>
</view>
<view v-else class="issues-empty">暂无期数</view>
</view>
</scroll-view>
<view v-if="showFlip" class="flip-overlay" @touchmove.stop.prevent>
<view class="flip-mask" @tap="closeFlip"></view>
<view class="flip-content" @tap.stop>
<FlipGrid ref="flipRef" :rewards="currentIssueRewards" :controls="false" />
<button class="overlay-close" @tap="closeFlip">关闭</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, getCurrentInstance } from 'vue'
import ElCard from '../../../components/ElCard.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards } from '../../../api/appUser'
import FlipGrid from '../../../components/FlipGrid.vue'
import { getActivityDetail, getActivityIssues, getActivityIssueRewards, drawActivityIssue, getActivityWinRecords } from '../../../api/appUser'
const detail = ref({})
const issues = ref([])
const rewardsMap = ref({})
const currentIssueId = ref('')
const selectedIssueIndex = ref(0)
const showIssues = computed(() => (detail.value && detail.value.status !== 2))
const activityId = ref('')
const tabActive = ref('pool')
const winRecords = ref([])
const drawLoading = ref(false)
const points = ref(0)
const flipRef = ref(null)
const showFlip = ref(false)
const currentIssueRewards = computed(() => (currentIssueId.value && rewardsMap.value[currentIssueId.value]) ? rewardsMap.value[currentIssueId.value] : [])
const currentIssueTitle = computed(() => {
const arr = issues.value || []
const cur = arr[selectedIssueIndex.value]
const t = (cur && (cur.title || ('第' + (cur.no || '-') + '期'))) || '-'
return t
})
function statusToText(s) {
if (s === 1) return '进行中'
@ -77,75 +90,239 @@ function normalizeIssues(list) {
status_text: i.status_text ?? (i.status === 1 ? '进行中' : i.status === 0 ? '未开始' : i.status === 2 ? '已结束' : '')
}))
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function truthy(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
if (!s) return false
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是' || s === 'boss是真的' || s === 'boss' || s === '大boss'
}
function detectBoss(i) {
return truthy(i.is_boss) || truthy(i.boss) || truthy(i.isBoss) || truthy(i.boss_true) || truthy(i.boss_is_true) || truthy(i.bossText) || truthy(i.tag)
}
function normalizeRewards(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? String(idx),
title: i.title ?? i.name ?? '',
image: i.image ?? i.img ?? i.pic ?? i.banner ?? '',
odds: i.odds ?? i.rate ?? i.probability ?? i.prob ?? '',
rarity: i.rarity ?? i.rarity_name ?? ''
const items = arr.map((i, idx) => ({
id: i.product_id ?? i.id ?? String(idx),
title: i.name ?? i.title ?? '',
image: cleanUrl(i.product_image ?? i.image ?? i.img ?? i.pic ?? i.banner ?? ''),
weight: Number(i.weight) || 0,
boss: detectBoss(i)
}))
const total = items.reduce((acc, it) => acc + (it.weight > 0 ? it.weight : 0), 0)
const enriched = items.map(it => ({
...it,
percent: total > 0 ? Math.round((it.weight / total) * 1000) / 10 : 0
}))
enriched.sort((a, b) => (b.percent - a.percent))
return enriched
}
function normalizeWinRecords(list) {
const arr = unwrap(list)
return arr.map((i, idx) => ({
id: i.id ?? i.record_id ?? i.product_id ?? String(idx),
title: i.title ?? i.name ?? i.product_name ?? '',
image: cleanUrl(i.image ?? i.img ?? i.pic ?? i.product_image ?? ''),
count: Number(i.count ?? i.total ?? i.qty ?? 1) || 1,
percent: i.percent !== undefined ? Math.round(Number(i.percent) * 10) / 10 : undefined
}))
}
function isFresh(ts) {
const now = Date.now()
const v = Number(ts || 0)
return now - v < 24 * 60 * 60 * 1000
}
function getRewardCache() {
const obj = uni.getStorageSync('reward_cache_v1') || {}
return typeof obj === 'object' && obj ? obj : {}
}
async function fetchRewardsForIssues(activityId) {
const list = issues.value || []
const promises = list.map(it => getActivityIssueRewards(activityId, it.id))
const cache = getRewardCache()
const act = cache[activityId] || {}
const toFetch = []
list.forEach(it => {
const c = act[it.id]
if (c && isFresh(c.ts) && Array.isArray(c.value)) {
rewardsMap.value = { ...(rewardsMap.value || {}), [it.id]: c.value }
} else {
toFetch.push(it)
}
})
if (!toFetch.length) return
const promises = toFetch.map(it => getActivityIssueRewards(activityId, it.id))
const results = await Promise.allSettled(promises)
const nextAct = { ...act }
results.forEach((res, i) => {
const issueId = list[i] && list[i].id
const issueId = toFetch[i] && toFetch[i].id
if (!issueId) return
const value = res.status === 'fulfilled' ? normalizeRewards(res.value) : []
rewardsMap.value = { ...(rewardsMap.value || {}), [issueId]: value }
nextAct[issueId] = { value, ts: Date.now() }
})
cache[activityId] = nextAct
uni.setStorageSync('reward_cache_v1', cache)
}
async function fetchIssues(id) {
const data = await getActivityIssues(id)
issues.value = normalizeIssues(data)
const latestId = pickLatestIssueId(issues.value)
setSelectedById(latestId)
await fetchRewardsForIssues(id)
}
async function fetchWinRecords(activityId) {
try {
const data = await getActivityWinRecords(activityId, 1, 50)
winRecords.value = normalizeWinRecords(data)
} catch (e) {
winRecords.value = []
}
}
function pickLatestIssueId(list) {
const arr = Array.isArray(list) ? list : []
let latest = arr[arr.length - 1] && arr[arr.length - 1].id
let maxNo = -Infinity
arr.forEach(i => {
const n = Number(i.no)
if (!Number.isNaN(n) && Number.isFinite(n) && n > maxNo) {
maxNo = n
latest = i.id
}
})
return latest || (arr[0] && arr[0].id) || ''
}
function setSelectedById(id) {
const arr = issues.value || []
const idx = Math.max(0, arr.findIndex(x => x && x.id === id))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function onIssueChange(e) {
// deprecated picker
}
function prevIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value - 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
function nextIssue() {
const arr = issues.value || []
const idx = Math.max(0, Math.min(arr.length - 1, selectedIssueIndex.value + 1))
selectedIssueIndex.value = idx
const cur = arr[idx]
currentIssueId.value = (cur && cur.id) || ''
}
async function ensureElCard() {
const inst = getCurrentInstance()
const app = inst && inst.appContext && inst.appContext.app
let comp = null
if (typeof window !== 'undefined' && window.ElementPlus && window.ElementPlus.ElCard) {
comp = window.ElementPlus.ElCard
} else {
comp = ElCard
}
if (app && !app.component('el-card')) app.component('el-card', comp)
}
function onPreviewBanner() {
const url = detail.value.banner || ''
if (url) uni.previewImage({ urls: [url], current: url })
}
function onParticipate() {
uni.showToast({ title: '功能待接入', icon: 'none' })
function onMachineDraw(count) {
showFlip.value = true
try { if (flipRef.value && flipRef.value.reset) flipRef.value.reset() } catch (_) {}
const aid = activityId.value || ''
const iid = currentIssueId.value || ''
if (!aid || !iid) { uni.showToast({ title: '期数未选择', icon: 'none' }); return }
drawLoading.value = true
const times = Math.max(1, Number(count || 1))
const calls = Array(times).fill(0).map(() => drawActivityIssue(aid, iid))
Promise.allSettled(calls).then(list => {
drawLoading.value = false
const items = list.map(r => {
const obj = r.status === 'fulfilled' ? r.value : {}
const data = obj && (obj.data || obj.result || obj.reward || obj.item || obj)
const title = String((data && (data.title || data.name || data.product_name)) || '未知奖励')
const image = String((data && (data.image || data.img || data.pic || data.product_image)) || '')
return { title, image }
})
if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults(items)
}).catch(() => { drawLoading.value = false; if (flipRef.value && flipRef.value.revealResults) flipRef.value.revealResults([{ title: '抽选失败', image: '' }]) })
}
function onMachineTry() {
const list = rewardsMap.value[currentIssueId.value] || []
if (!list.length) { uni.showToast({ title: '暂无奖池', icon: 'none' }); return }
const idx = Math.floor(Math.random() * list.length)
const it = list[idx]
uni.showModal({ title: '试一试', content: it.title || '随机预览', showCancel: false, success: () => { if (it.image) uni.previewImage({ urls: [it.image], current: it.image }) } })
}
onLoad((opts) => {
const id = (opts && opts.id) || ''
if (id) {
activityId.value = id
fetchDetail(id)
fetchIssues(id)
fetchWinRecords(id)
}
ensureElCard()
})
function closeFlip() { showFlip.value = false }
</script>
<style scoped>
.page { height: 100vh }
.page { height: 100vh; padding-bottom: 140rpx }
.banner { padding: 24rpx }
.banner-img { width: 100% }
.header { padding: 0 24rpx }
.title { font-size: 36rpx; font-weight: 700 }
.title { font-size: 36rpx; font-weight: 700; color: #DD2C00; text-align: center }
.meta { margin-top: 8rpx; font-size: 26rpx; color: #666 }
.actions { display: flex; padding: 24rpx; gap: 16rpx }
.btn { flex: 1 }
.primary { background-color: #007AFF; color: #fff }
.draw-actions { display: flex; gap: 12rpx; padding: 24rpx }
.draw-btn { flex: 1; background: #007AFF; color: #fff; border-radius: 8rpx }
.draw-btn.secondary { background: #ffd166; color: #6b4b1f }
.flip-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 10000 }
.flip-mask { position: absolute; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1 }
.flip-content { position: relative; display: flex; flex-direction: column; height: 100%; padding: 24rpx; z-index: 2 }
.overlay-close { background: #ffd166; color: #6b4b1f; border-radius: 999rpx; align-self: flex-end }
.issues { background: #fff; border-radius: 12rpx; margin: 0 24rpx 24rpx; padding: 16rpx }
.issues-title { font-size: 30rpx; font-weight: 600; margin-bottom: 12rpx }
.issues-list { }
.issue-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f0f0f0 }
.issue-item:last-child { border-bottom: 0 }
.issue-title { font-size: 26rpx }
.issue-status { font-size: 24rpx; color: #666 }
.rewards { width: 100%; margin-top: 12rpx }
.issue-switch { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin: 0 24rpx 24rpx }
.switch-btn { width: 72rpx; height: 72rpx; border-radius: 999rpx; background: #fff3df; border: 2rpx solid #f0c58a; color: #8a5a2b }
.issue-title { font-size: 28rpx; color: #6b4b1f; background: #ffdfaa; border-radius: 12rpx; padding: 8rpx 16rpx }
.reward { display: flex; align-items: center; margin-bottom: 8rpx }
.reward-img { width: 80rpx; height: 80rpx; border-radius: 8rpx; margin-right: 12rpx; background: #f5f5f5 }
.reward-texts { display: flex; flex-direction: column }
.reward-title { font-size: 26rpx }
.reward-meta { font-size: 22rpx; color: #888 }
.reward-card { background: #fff; border-radius: 12rpx; overflow: hidden; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); margin-bottom: 12rpx }
.el-reward-card { margin-bottom: 12rpx }
.el-card-header { display: flex; align-items: center; justify-content: space-between }
.el-card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.card-image-wrap { position: relative; padding-bottom: 48rpx }
.card-image { width: 100%; height: auto; display: block; background: #f0f4ff; position: relative; z-index: 1 }
.prob-corner { position: absolute; background: rgba(221,82,77,0.9); color: #fff; font-size: 22rpx; padding: 6rpx 12rpx; border-radius: 999rpx; z-index: 2 }
.prob-corner.tl { top: 12rpx; left: 12rpx }
.card-body { display: flex; align-items: center; justify-content: space-between; padding: 12rpx }
.card-title { font-size: 28rpx; color: #222; flex: 1; margin-right: 8rpx; word-break: break-all }
.badge-boss { background: #ff9f0a; color: #222; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.badge-count { background: #ffd166; color: #6b4b1f; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx }
.rewards-empty { font-size: 24rpx; color: #999 }
.issues-empty { font-size: 24rpx; color: #999 }
</style>

View File

@ -2,8 +2,8 @@
<scroll-view class="page" scroll-y>
<view class="h1">购买协议</view>
<view class="meta">生效日期2025年11月18日</view>
<view class="meta">运营方公司全称</view>
<view class="p">购买协议适用于您在您的小程序名称以下简称本平台购买盲盒商品的行为当您点击立即购买并完成支付时即视为您已阅读理解并同意本协议全部内容</view>
<view class="meta">运营方柯大鸭潮玩</view>
<view class="p">购买协议适用于您在柯大鸭潮玩以下简称本平台购买盲盒商品的行为当您点击立即购买并完成支付时即视为您已阅读理解并同意本协议全部内容</view>
<view class="h2">商品说明</view>
<view class="ol">
<view class="li">盲盒特性本平台所售盲盒为系列化商品包装外观一致内部款式随机具体款式无法提前指定或预知</view>
@ -22,7 +22,7 @@
</view>
<view class="h2">售后服务</view>
<view class="ol">
<view class="li">质量问题如商品破损漏发错发非盲盒系列商品请在签收后2到4小时内联系客服并提供凭证如开箱视频照片经核实后平台将为您补发换货或退款</view>
<view class="li">质量问题如商品破损漏发错发非盲盒系列商品请在签收后2小时内联系客服并提供凭证如开箱视频照片经核实后平台将为您补发换货或退款</view>
<view class="li">非质量问题如抽中重复款式不喜欢款式未抽中隐藏款等不支持无理由退换货</view>
<view class="li">拆封后商品出于卫生与二次销售考虑已拆封盲盒恕不退换质量问题除外</view>
</view>
@ -45,10 +45,9 @@
<view class="h2">协议效力</view>
<view class="p">本购买协议为用户协议的补充两者冲突时以本协议中关于交易的条款为准未尽事宜依照消费者权益保护法电子商务法等法律法规执行</view>
<view class="h2">联系我们</view>
<view class="p">售后专线service@yourdomain.com</view>
<view class="p">工作时间工作日 9:0018:00</view>
<view class="p">运营主体公司全称</view>
<view class="p">统一社会信用代码XXXXXXXXXXXXXX</view>
<view class="p">售后专线请联系企业客服客服将在工作时间内为您服务</view>
<view class="p">工作时间工作日 13:0004:00</view>
<view class="p">运营主体柯大鸭潮玩</view>
<view class="tip">理性消费提醒盲盒是一种娱乐消费形式请根据自身经济能力合理购买切勿沉迷或过度投入</view>
</scroll-view>
</template>

View File

@ -2,8 +2,8 @@
<scroll-view class="page" scroll-y>
<view class="h1">用户协议</view>
<view class="meta">生效日期2025年11月18日</view>
<view class="meta">运营方公司全称</view>
<view class="p">欢迎您使用您的小程序名称以下简称本平台提供的服务请您在注册登录或使用本平台前认真阅读并充分理解本用户协议以下简称本协议一旦您完成注册登录或以任何方式使用本平台服务即视为您已完全接受本协议全部条款如您不同意请勿使用本平台</view>
<view class="meta">运营方柯大鸭潮玩</view>
<view class="p">欢迎您使用柯大鸭潮玩以下简称本平台提供的服务请您在注册登录或使用本平台前认真阅读并充分理解本用户协议以下简称本协议一旦您完成注册登录或以任何方式使用本平台服务即视为您已完全接受本协议全部条款如您不同意请勿使用本平台</view>
<view class="h2">协议范围</view>
<view class="p">本协议规范您作为用户在本平台注册浏览互动参与活动等行为是您与本平台之间的基本权利义务约定</view>
<view class="h2">用户资格</view>
@ -44,10 +44,9 @@
<view class="h2">法律适用与争议解决</view>
<view class="p">本协议适用中华人民共和国法律因本协议引起的争议双方应协商解决协商不成的提交本平台运营方所在地有管辖权的人民法院诉讼解决</view>
<view class="h2">联系我们</view>
<view class="p">客服邮箱service@yourdomain.com</view>
<view class="p">客服电话400-XXX-XXXX工作日 9:0018:00</view>
<view class="p">运营主体公司全称</view>
<view class="p">地址公司注册地址</view>
<view class="p">客服邮箱请通过企业客服联系我们客服将在工作时间内为您服务</view>
<view class="p">工作时间工作日 13:0004:00</view>
<view class="p">运营主体柯大鸭潮玩</view>
<view class="tip">温馨提示盲盒具有随机性和娱乐性请理性参与避免沉迷未成年人禁止参与购买</view>
</scroll-view>
</template>

View File

@ -21,15 +21,21 @@
<view class="activity-section">
<view class="section-title">活动</view>
<view v-if="activities.length" class="activity-grid">
<view class="activity-item" v-for="a in activities" :key="a.id" @tap="onActivityTap(a)">
<image v-if="a.image" class="activity-thumb" :src="a.image" mode="aspectFill" />
<view v-else class="banner-fallback">
<text class="banner-fallback-text">{{ a.title || '活动敬请期待' }}</text>
<view v-if="activityGroups.length">
<scroll-view class="tabs" scroll-x>
<view class="tab" v-for="g in activityGroups" :key="g.name" :class="{ active: g.name === selectedGroupName }" @tap="onSelectGroup(g.name)">{{ g.name }}</view>
</scroll-view>
<view v-if="activeGroupItems.length" class="activity-grid">
<view class="activity-item" v-for="a in activeGroupItems" :key="a.id" @tap="onActivityTap(a)">
<image v-if="a.image" class="activity-thumb" :src="a.image" mode="aspectFill" />
<view v-else class="banner-fallback">
<text class="banner-fallback-text">{{ a.title || '活动敬请期待' }}</text>
</view>
<text class="activity-name">{{ a.title }}</text>
<text class="activity-desc" v-if="a.subtitle">{{ a.subtitle }}</text>
</view>
<text class="activity-name">{{ a.title }}</text>
<text class="activity-desc" v-if="a.subtitle">{{ a.subtitle }}</text>
</view>
<view v-else class="activity-empty">该分组暂无活动</view>
</view>
<view v-else class="activity-empty">暂无活动</view>
</view>
@ -43,7 +49,8 @@ export default {
return {
notices: [],
banners: [],
activities: []
activities: [],
selectedGroupName: ''
}
},
computed: {
@ -61,6 +68,22 @@ export default {
{ id: 'ph-2', title: '敬请期待', image: '' },
{ id: 'ph-3', title: '更多活动请关注', image: '' }
]
},
activityGroups() {
const list = Array.isArray(this.activities) ? this.activities : []
const map = new Map()
list.forEach(a => {
const key = (a.category_name || '').trim() || '其他'
if (!map.has(key)) map.set(key, [])
map.get(key).push(a)
})
return Array.from(map.entries()).map(([name, items]) => ({ name, items }))
},
activeGroupItems() {
const groups = this.activityGroups
const name = this.selectedGroupName || (groups[0] && groups[0].name) || ''
const g = groups.find(x => x.name === name)
return g ? g.items : []
}
},
onShow() {
@ -82,6 +105,16 @@ export default {
this.loadHomeData()
},
methods: {
onSelectGroup(name) {
this.selectedGroupName = String(name || '')
},
updateSelectedGroup() {
const groups = this.activityGroups
if (!groups.length) { this.selectedGroupName = ''; return }
if (!groups.find(g => g.name === this.selectedGroupName)) {
this.selectedGroupName = groups[0].name
}
},
toArray(x) { return Array.isArray(x) ? x : [] },
unwrap(list) {
if (Array.isArray(list)) return list
@ -126,7 +159,7 @@ export default {
console.log('normalizeActivities input', list, 'unwrapped', arr)
const mapped = arr.map((i, idx) => ({
id: i.id ?? String(idx),
image: this.cleanUrl(i.banner ?? i.coverUrl ?? i.cover_url ?? i.image ?? i.img ?? i.pic ?? ''),
image: this.cleanUrl(i.image ?? i.banner ?? i.coverUrl ?? i.cover_url ?? i.img ?? i.pic ?? ''),
title: i.title ?? i.name ?? '',
subtitle: this.buildActivitySubtitle(i),
link: this.cleanUrl(i.linkUrl ?? i.link_url ?? i.link ?? i.url ?? ''),
@ -168,9 +201,11 @@ export default {
if (acRes.status === 'fulfilled') {
console.log('activities ok', acRes.value)
this.activities = this.normalizeActivities(acRes.value)
this.updateSelectedGroup()
} else {
console.error('activities error', acRes.reason)
this.activities = []
this.updateSelectedGroup()
}
console.log('home normalized', { notices: this.notices, banners: this.banners, activities: this.activities })
},
@ -216,6 +251,9 @@ export default {
.banner-fallback-text { color: #666; font-size: 28rpx }
.activity-section { background: #ffffff; border-radius: 12rpx; padding: 24rpx }
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 16rpx }
.tabs { white-space: nowrap; display: flex; gap: 12rpx; margin-bottom: 16rpx }
.tab { display: inline-flex; align-items: center; height: 56rpx; padding: 0 20rpx; border-radius: 999rpx; background: #f5f7fa; color: #555; font-size: 26rpx }
.tab.active { background: #007AFF; color: #fff }
.activity-grid { display: flex; flex-wrap: wrap; margin: -12rpx }
.activity-item { width: 50%; padding: 12rpx }
.activity-thumb { width: 100%; height: 200rpx; border-radius: 8rpx }

View File

@ -1,5 +1,18 @@
<template>
<view class="page">
<view v-if="showNotice" class="notice-mask">
<view class="notice-dialog">
<view class="notice-title">提示</view>
<view class="notice-content">由于价格浮动当前暂不支持自行兑换商品兑换请联系客服核对价格</view>
<view class="notice-actions">
<view class="notice-check" @tap="toggleHideForever">
<view class="check-box" :class="{ on: hideForever }"></view>
<text class="check-text">不再显示</text>
</view>
<button class="notice-btn" type="primary" @tap="onDismissNotice">我知道了</button>
</view>
</view>
</view>
<view v-if="loading" class="loading-wrap"><view class="spinner"></view></view>
<view class="products-section" v-else>
<view class="section-title">商品</view>
@ -53,6 +66,9 @@ const minPrice = ref('')
const maxPrice = ref('')
const displayCount = computed(() => (columns.value[0].length + columns.value[1].length))
const loadedMap = ref({})
const showNotice = ref(false)
const hideForever = ref(false)
const skipReloadOnce = ref(false)
function getKey(p) { return String((p && p.id) ?? '') + '|' + String((p && p.image) ?? '') }
function unwrap(list) {
if (Array.isArray(list)) return list
@ -146,6 +162,8 @@ function onProductTap(p) {
const imgs = (Array.isArray(products.value) ? products.value : []).map(x => x.image).filter(Boolean)
const current = p && p.image
if (current) {
skipReloadOnce.value = true
try { uni.setStorageSync('shop_skip_reload_once', '1') } catch (_) {}
uni.previewImage({ urls: imgs.length ? imgs : [current], current })
return
}
@ -249,14 +267,50 @@ onShow(async () => {
})
return
}
try {
const sess = String(uni.getStorageSync('app_session_id') || '')
const hiddenSess = String(uni.getStorageSync('shop_notice_hidden_session_id') || '')
const hiddenThisSession = !!(sess && hiddenSess && hiddenSess === sess)
showNotice.value = !hiddenThisSession
hideForever.value = hiddenThisSession
} catch (_) { showNotice.value = true; hideForever.value = false }
try {
const skip = !!uni.getStorageSync('shop_skip_reload_once')
if (skipReloadOnce.value || skip) {
skipReloadOnce.value = false
uni.setStorageSync('shop_skip_reload_once', '')
return
}
} catch (_) {}
loading.value = true
await loadProducts()
loading.value = false
})
function toggleHideForever() { hideForever.value = !hideForever.value }
function onDismissNotice() {
if (hideForever.value) {
try {
const sess = String(uni.getStorageSync('app_session_id') || '')
if (sess) uni.setStorageSync('shop_notice_hidden_session_id', sess)
} catch (_) {}
}
showNotice.value = false
}
</script>
<style scoped>
.page { padding: 24rpx }
.notice-mask { position: fixed; left: 0; top: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 9999; display: flex; align-items: center; justify-content: center }
.notice-dialog { width: 86%; max-width: 640rpx; background: #fff; border-radius: 16rpx; overflow: hidden; box-shadow: 0 12rpx 24rpx rgba(0,0,0,0.18) }
.notice-title { font-size: 32rpx; font-weight: 600; padding: 24rpx 24rpx 0 }
.notice-content { padding: 16rpx 24rpx; font-size: 26rpx; color: #333; line-height: 1.6 }
.notice-actions { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 24rpx 24rpx }
.notice-check { display: flex; align-items: center; gap: 8rpx }
.check-box { width: 28rpx; height: 28rpx; border-radius: 6rpx; border: 2rpx solid #007AFF; background: #fff }
.check-box.on { background: #007AFF }
.check-text { font-size: 26rpx; color: #555 }
.notice-btn { background: #007AFF; color: #fff; border-radius: 999rpx; padding: 0 28rpx }
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 16rpx }
.products-section { background: #ffffff; border-radius: 12rpx; padding: 24rpx; margin-top: 24rpx }
.loading-wrap { min-height: 60vh; display: flex; align-items: center; justify-content: center }