Files
2026-03-25 15:59:24 +08:00

543 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="coin-page" :style="{ paddingTop }">
<view class="coin-bg"></view>
<view class="coin-header">
<view class="header-left" @click="goBack">
<u-icon name="arrow-left" size="28" color="#1b1b1b"></u-icon>
</view>
<text class="header-title">金币中心</text>
<view class="header-right"></view>
</view>
<view class="purchase-card">
<view class="card-row between">
<text class="card-title">金币购买</text>
<view class="sign-info" @click="openSignPopup">
<u-icon name="calendar" size="24" color="#f37a5b"></u-icon>
<text class="sign-text">{{ todaySigned ? '今日已签到' : '签到领金币' }}</text>
</view>
</view>
<view class="amount-row">
<text class="amount-label">金币数量</text>
<u-number-box
v-model="coinAmount"
:step="1"
:min="1"
:integer="true"
></u-number-box>
</view>
<text class="tips">最少购买 1 枚金币,步长 1,可自由增减。</text>
<u-button
type="primary"
shape="circle"
:loading="submitting"
:disabled="submitting"
@click="handlePurchase"
>
立即购买
</u-button>
</view>
<view class="record-section">
<view class="record-header">
<text class="record-title">金币记录</text>
<text class="record-count">共 {{ recordList.length }} 条</text>
</view>
<view v-if="loading" class="record-empty">正在加载记录...</view>
<view v-else-if="!recordList.length" class="record-empty">暂无金币记录</view>
<view v-else class="record-list">
<view
v-for="item in recordList"
:key="item.id || item.coinLogId || item.createTime"
class="record-item"
>
<view class="record-left">
<view class="flex flex-col justify-start items-start">
<text class="record-name">{{ item.desc || item.title || '金币变动' }}</text>
<template v-if="item.type === '+' && item.desc === '金币购买'">
<u-tag type="error" v-if="item.status === 0" shape="circle" size="mini" text="未支付"></u-tag>
<u-tag type="success" v-else shape="circle" size="mini" text="已支付"></u-tag>
</template>
</view>
<text class="record-time">
{{ formatTime(item.createTime || item.createdAt || item.updateTime) }}
</text>
</view>
<text
class="record-coin"
:class="{ positive: item.type === '+' }"
>
{{ formatCoinChange(item) }}
</text>
</view>
</view>
</view>
<Login ref="loginPopup" :isHide="true" @done="handleLoginSuccess"/>
<u-popup :show="signPopupVisible" mode="bottom" round="20" @close="signPopupVisible = false">
<view class="sign-popup">
<view class="sign-header">
<text class="sign-title">连续签到</text>
<view class="sign-status" :class="{active: !todaySigned}">
{{ todaySigned ? '今日已签到' : '未签到' }}
</view>
</view>
<view class="sign-list">
<view
class="sign-day"
v-for="day in signCalendar"
:key="day.date"
:class="[{signed: day.signed}, {today: day.isToday}]"
>
<text class="day-text">{{ day.display }}</text>
<text class="status-text">{{ day.signed ? '已签' : '未签' }}</text>
</view>
</view>
<u-button
type="primary"
shape="circle"
:disabled="todaySigned || signing"
:loading="signing"
class="sign-btn"
@click="handleSign"
>
{{ todaySigned ? '今日已签到' : '立即签到' }}
</u-button>
</view>
</u-popup>
</view>
</template>
<script>
import {coinAddReq, coinListReq, signLogAddReq, signLogListReq} from '@/api/coin'
import {dayjs} from '@/uni_modules/iRainna-dayjs/js_sdk/dayjs.min.js'
import {getUserInfo} from '@/util/user'
import Login from '@/components/Login.vue'
export default {
components: {Login},
data() {
return {
coinAmount: 1,
recordList: [],
loading: false,
submitting: false,
paddingTop: '60px',
signPopupVisible: false,
signLogs: [],
todaySigned: false,
signing: false,
signCalendar: []
}
},
onShow() {
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 20
this.paddingTop = `${statusBarHeight + 40}px`
this.ensureLoginAndLoad()
},
methods: {
ensureLoginAndLoad() {
if (!this.ensureLogin()) {
this.recordList = []
return false
}
this.fetchRecords()
this.fetchSignLogs()
return true
},
ensureLogin() {
const {token} = getUserInfo()
if (!token) {
this.$nextTick(() => {
if (this.$refs.loginPopup) this.$refs.loginPopup.open()
})
return false
}
return true
},
async fetchRecords() {
this.loading = true
try {
const {data} = await coinListReq()
if (Array.isArray(data)) {
this.recordList = data
} else if (Array.isArray(data?.records)) {
this.recordList = data.records
} else {
this.recordList = []
}
} catch (error) {
uni.showToast({title: '加载失败,请稍后重试', icon: 'none'})
} finally {
this.loading = false
}
},
async handlePurchase() {
if (this.submitting) return
if (!this.ensureLogin()) return
if (!this.coinAmount || this.coinAmount < 1) {
uni.showToast({title: '最少购买 1 枚金币', icon: 'none'})
this.coinAmount = 1
return
}
this.submitting = true
try {
const {data} = await coinAddReq({
num: this.coinAmount,
type: '+',
desc: '金币购买'
})
const _this = this
uni.requestPayment({
provider: data.provider,
timeStamp: data.timeStamp, // 时间戳
nonceStr: data.nonceStr, // 随机字符串
package: data.package,
signType: data.signType, // 签名算法
paySign: data.paySign, // 签名
success: function (res) {
uni.showToast({
title: '支付成功',
icon: 'success'
});
_this.fetchRecords()
},
fail: function (err) {
console.log('支付失败', err);
}
})
} catch (error) {
const msg = error?.msg || error?.message || '购买失败,请稍后再试'
uni.showToast({title: msg, icon: 'none'})
} finally {
this.submitting = false
}
},
formatTime(value) {
if (!value) return '--'
const time = dayjs(value)
if (typeof time.isValid === 'function' && !time.isValid()) return value
return time.format('YYYY-MM-DD HH:mm')
},
getCoinValue(item) {
const fields = ['coin', 'coinNum', 'number', 'amount', 'value', 'change']
for (const field of fields) {
if (item[field] !== undefined && item[field] !== null) {
const num = Number(item[field])
if (!Number.isNaN(num)) return num
}
}
return 0
},
formatCoinChange(item) {
return `${item.type}${item.num}`
},
handleLoginSuccess() {
this.fetchRecords()
this.fetchSignLogs()
},
async fetchSignLogs() {
if (!this.ensureLogin()) return
try {
const {data} = await signLogListReq()
const list = Array.isArray(data) ? data : Array.isArray(data?.records) ? data.records : []
this.signLogs = list
this.todaySigned = list.some(item => dayjs(item.date).isSame(dayjs(), 'day'))
this.buildSignCalendar()
} catch (error) {
this.signLogs = []
this.todaySigned = false
}
},
buildSignCalendar() {
const today = dayjs()
const calendar = []
for (let i = 6; i >= 0; i--) {
const date = today.subtract(i, 'day')
const formatted = date.format('YYYY-MM-DD')
const signed = this.signLogs.some(item => dayjs(item.date).isSame(date, 'day'))
calendar.push({
date: formatted,
signed,
isToday: date.isSame(today, 'day'),
display: date.format('MM/DD')
})
}
this.signCalendar = calendar
},
openSignPopup() {
if (!this.ensureLogin()) return
this.signPopupVisible = true
if (!this.signCalendar.length) {
this.buildSignCalendar()
}
},
async handleSign() {
if (this.signing || this.todaySigned) return
if (!this.ensureLogin()) return
this.signing = true
const today = dayjs().format('YYYY-MM-DD')
try {
await signLogAddReq({date: today})
uni.showToast({title: '签到成功', icon: 'success'})
this.todaySigned = true
this.signLogs.push({date: today})
this.buildSignCalendar()
setTimeout(() => {
this.signPopupVisible = false
this.fetchRecords()
}, 1000)
} catch (error) {
const msg = error?.msg || error?.message || '签到失败'
uni.showToast({title: msg, icon: 'none'})
} finally {
this.signing = false
}
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({url: '/pages/user/index'})
}
}
}
}
</script>
<style scoped lang="scss">
.coin-page {
position: relative;
min-height: 100vh;
padding: 40rpx 30rpx 80rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #e1ecff 0%, #f5f0ff 45%, #fef8ff 100%);
}
.coin-bg {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.7);
filter: blur(40rpx);
z-index: 0;
}
.coin-header {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
.header-left,
.header-right {
width: 80rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.header-right {
justify-content: flex-end;
}
.header-title {
font-size: 34rpx;
font-weight: 600;
color: #1b1b1b;
}
.purchase-card {
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.94);
border-radius: 32rpx;
padding: 40rpx 32rpx;
box-shadow: 0 20rpx 50rpx rgba(117, 148, 255, 0.25);
margin-bottom: 30rpx;
}
.card-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
justify-content: center;
}
.card-row.between {
justify-content: space-between;
}
.sign-info {
display: flex;
align-items: center;
gap: 10rpx;
padding: 10rpx 20rpx;
border-radius: 999rpx;
background: rgba(243, 122, 91, 0.12);
color: #f37a5b;
font-size: 24rpx;
}
.sign-popup {
padding: 40rpx 30rpx 60rpx;
}
.sign-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
.sign-title {
font-size: 30rpx;
font-weight: 600;
color: #1b1b1b;
}
.sign-status {
padding: 12rpx 30rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: #f5f5f5;
color: #7a7a7a;
}
.sign-status.active {
background: rgba(52, 199, 89, 0.15);
color: #34c759;
}
.sign-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
margin-bottom: 40rpx;
}
.sign-day {
background: #f7f8ff;
border-radius: 18rpx;
padding: 20rpx 10rpx;
text-align: center;
font-size: 24rpx;
color: #5e5f7a;
}
.sign-day.today {
border: 2rpx solid #f37a5b;
}
.sign-day.signed {
background: rgba(52, 199, 89, 0.15);
color: #1f7338;
}
.sign-btn {
width: 100%;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1b1b1b;
}
.amount-row {
margin: 30rpx 0 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.amount-label {
font-size: 28rpx;
color: #5b5f7b;
}
.tips {
font-size: 24rpx;
color: #8f90aa;
margin-bottom: 30rpx;
}
.record-section {
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.9);
border-radius: 32rpx;
padding: 30rpx;
box-shadow: 0 20rpx 40rpx rgba(150, 164, 255, 0.2);
}
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.record-title {
font-size: 30rpx;
font-weight: 600;
color: #1f2251;
}
.record-count {
font-size: 24rpx;
color: #a0a2c2;
}
.record-empty {
text-align: center;
color: #a0a2c2;
font-size: 26rpx;
padding: 40rpx 0;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26rpx 0;
border-bottom: 1px solid rgba(226, 232, 255, 0.7);
}
.record-item:last-child {
border-bottom: none;
}
.record-left {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.record-name {
font-size: 28rpx;
color: #1f2251;
}
.record-time {
font-size: 24rpx;
color: #a0a2c2;
}
.record-coin {
font-size: 30rpx;
font-weight: 600;
color: #f26c5b;
}
.record-coin.positive {
color: #00c78c;
}
</style>