|
- <template>
- <view class="wrap">
- <!-- 余额头卡 -->
- <view class="head">
- <view class="head-left">
- <text class="kicker">获得点数</text>
- <text class="title">当前 {{ balance }} 点</text>
- <text class="desc">完成下面的任务即可获得点数,点数可用于全部 AI 工具</text>
- </view>
- <view class="head-actions">
- <view class="head-btn" @click="goBill">明细</view>
- <view class="head-btn primary" @click="goRecharge">充值</view>
- </view>
- </view>
-
- <!-- 加载骨架:等待超过 300ms 也有反馈 -->
- <view v-if="loading" class="skeleton-list">
- <view v-for="i in 3" :key="i" class="skeleton-row"></view>
- </view>
-
- <template v-else>
- <!-- 任务列表:只渲染已开启的任务 -->
- <view v-if="checkinTask.enabled" class="task-card checkin-card">
- <view class="task-main">
- <view class="task-icon checkin">
- <up-icon name="calendar" color="#1f8cff" size="22" />
- </view>
- <view class="task-copy">
- <text class="task-title">{{ pointCopy(checkinTask.title, '每日签到') }}</text>
- <text class="task-desc">{{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }}</text>
- <text class="task-progress" :class="{ success: checkinTask.checked_in }">
- {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }}
- </text>
- </view>
- <view class="task-action">
- <view class="reward-chip">+{{ checkinTask.points_per_checkin }}</view>
- <view
- class="task-btn"
- :class="{ disabled: checkinBtnDisabled }"
- @click="claimCheckin"
- >
- {{ checkinBtnText }}
- </view>
- </view>
- </view>
- </view>
-
- <view v-if="adTask.enabled" class="task-card">
- <view class="task-main">
- <view class="task-icon ad">
- <text class="icon-text">▶</text>
- </view>
- <view class="task-copy">
- <text class="task-title">{{ pointCopy(adTask.title, '看视频获得点数') }}</text>
- <text class="task-desc">{{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }}</text>
- <text class="task-progress">{{ adProgressText }}</text>
- </view>
- <view class="task-action">
- <view class="reward-chip">+{{ adTask.points_per_ad }}</view>
- <view
- class="task-btn"
- :class="{ disabled: adBtnDisabled }"
- @click="watchAd"
- >
- {{ adBtnText }}
- </view>
- </view>
- </view>
- </view>
-
- <view v-if="inviteTask.enabled" class="task-card">
- <view class="task-main">
- <view class="task-icon invite">
- <text class="icon-text">+</text>
- </view>
- <view class="task-copy">
- <text class="task-title">邀请好友注册</text>
- <text class="task-desc">
- 好友注册成功,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点
- </text>
- <text class="task-progress">
- 已邀请 {{ inviteTask.stats.success || 0 }} 人 · 累计获得 {{ inviteTask.stats.inviter_points || 0 }} 点
- </text>
- </view>
- <view class="task-action">
- <view class="reward-chip">+{{ inviteTask.inviter_points }}</view>
- <view class="task-btn" @click="goInvite">去邀请</view>
- </view>
- </view>
- </view>
-
- <!-- 所有任务都未开启:不给空白页,直接引导充值 -->
- <view v-if="!checkinTask.enabled && !adTask.enabled && !inviteTask.enabled" class="empty-state">
- <text class="empty-title">暂无可做的任务</text>
- <text class="empty-desc">活动暂未开启,可以先通过充值获得点数。</text>
- <view class="empty-action" @click="goRecharge">去充值</view>
- </view>
-
- <!-- 规则 -->
- <view v-if="ruleLines.length" class="rule-card">
- <text class="rule-title">规则说明</text>
- <text v-for="line in ruleLines" :key="line" class="rule-line">· {{ line }}</text>
- </view>
- </template>
- </view>
- </template>
-
- <script setup>
- import { computed, ref } from 'vue'
- import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
- import { pointApi } from '@/api/index.js'
- import { useUserStore } from '@/store/user.js'
- import { useApp } from '@/utils/useApp.js'
-
- const userStore = useUserStore()
- const { toast } = useApp()
-
- const loading = ref(true)
- const balance = ref(0)
- const rewarding = ref(false)
- const checkingIn = ref(false)
- const waitSeconds = ref(0)
- const checkinTask = ref({
- enabled: false,
- points_per_checkin: 0,
- checked_in: false,
- can_checkin: false,
- title: '',
- desc: '',
- rule_text: ''
- })
- const inviteTask = ref({ enabled: false, inviter_points: 0, invitee_points: 0, rule_text: '', stats: {} })
- const adTask = ref({
- enabled: false,
- points_per_ad: 0,
- daily_limit: 0,
- today_used: 0,
- remain: 0,
- ad_unit_id: '',
- title: '',
- desc: '',
- rule_text: ''
- })
-
- // 激励视频实例:官方约定是页面内单例、不可跨页复用,所以只在本页创建
- let videoAd = null
- let currentTicket = ''
- let waitTimer = 0
-
- const adRemainText = computed(() => {
- if (adTask.value.daily_limit <= 0) return '不限次数'
- return `今日 ${adTask.value.today_used}/${adTask.value.daily_limit} 次`
- })
- const adProgressText = computed(() => {
- if (!adTask.value.enabled) return ''
- if (adTask.value.remain === 0) return '今日次数已用完,明天再来'
- return adRemainText.value
- })
- const adBtnDisabled = computed(
- () => rewarding.value || adTask.value.remain === 0 || waitSeconds.value > 0
- )
- const adBtnText = computed(() => {
- if (rewarding.value) return '发放中'
- if (adTask.value.remain === 0) return '已领完'
- if (waitSeconds.value > 0) return `${waitSeconds.value}s`
- return `看视频 +${adTask.value.points_per_ad}`
- })
- const checkinBtnDisabled = computed(() => checkingIn.value || checkinTask.value.checked_in)
- const checkinBtnText = computed(() => {
- if (checkingIn.value) return '签到中'
- if (checkinTask.value.checked_in) return '已签到'
- return '立即签到'
- })
- const ruleLines = computed(() => {
- const lines = []
- if (checkinTask.value.enabled && checkinTask.value.rule_text) lines.push(...splitRule(checkinTask.value.rule_text))
- if (adTask.value.enabled && adTask.value.rule_text) lines.push(...splitRule(adTask.value.rule_text))
- if (inviteTask.value.enabled && inviteTask.value.rule_text) lines.push(...splitRule(inviteTask.value.rule_text))
- return lines
- })
-
- function splitRule(text) {
- return pointCopy(text)
- .split(/[\n;;]/)
- .map((item) => item.trim())
- .filter(Boolean)
- }
-
- onLoad(() => {
- loadConfig()
- })
-
- onShow(() => {
- // 从充值/邀请页返回时刷新余额与进度
- if (!loading.value) loadConfig(true)
- })
-
- onUnload(() => {
- clearInterval(waitTimer)
- destroyAd()
- })
-
- async function loadConfig(silent = false) {
- try {
- const res = await pointApi.getEarnConfig()
- const data = res.data || {}
- balance.value = (data.balance && data.balance.points) || 0
- checkinTask.value = Object.assign({}, checkinTask.value, data.checkin || {})
- inviteTask.value = Object.assign({ stats: {} }, data.invite || {})
- adTask.value = Object.assign({}, adTask.value, data.ad || {})
- startWait(adTask.value.wait_seconds || 0)
- if (adTask.value.enabled) initAd()
- } catch (e) {
- if (!silent) toast((e && e.msg) || '加载失败')
- } finally {
- loading.value = false
- }
- }
-
- /** 冷却倒计时:后端下发剩余秒数,前端只做展示 */
- function startWait(seconds) {
- clearInterval(waitTimer)
- waitSeconds.value = Number(seconds || 0)
- if (waitSeconds.value <= 0) return
- waitTimer = setInterval(() => {
- waitSeconds.value -= 1
- if (waitSeconds.value <= 0) clearInterval(waitTimer)
- }, 1000)
- }
-
- function initAd() {
- // #ifdef MP-WEIXIN
- if (videoAd || !adTask.value.ad_unit_id) return
- if (!wx.createRewardedVideoAd) return
- videoAd = wx.createRewardedVideoAd({ adUnitId: adTask.value.ad_unit_id })
- videoAd.onError((err) => {
- console.log('激励视频加载失败', err)
- cancelTicket('广告拉取失败')
- rewarding.value = false
- toast('广告暂时拉取失败,请稍后再试')
- })
- videoAd.onClose((res) => {
- // 只有完整看完才发奖,中途退出作废凭证且不占次数
- if (res && res.isEnded) {
- claimReward()
- } else {
- cancelTicket('未看完')
- rewarding.value = false
- toast('未看完视频,本次不发放点数')
- }
- })
- // #endif
- }
-
- function destroyAd() {
- // #ifdef MP-WEIXIN
- videoAd = null
- // #endif
- }
-
- async function watchAd() {
- if (!ensureLogin()) return
- if (adBtnDisabled.value) return
-
- // #ifndef MP-WEIXIN
- toast('请在微信小程序中观看视频获得点数')
- return
- // #endif
-
- rewarding.value = true
- try {
- const res = await pointApi.getAdTicket()
- const data = res.data || {}
- currentTicket = data.ticket || ''
- if (data.ad) adTask.value = Object.assign({}, adTask.value, data.ad)
- if (!currentTicket) throw new Error('凭证获取失败')
-
- initAd()
- if (!videoAd) {
- rewarding.value = false
- cancelTicket('广告组件不可用')
- toast('当前环境不支持激励视频')
- return
- }
- await showAd()
- } catch (e) {
- rewarding.value = false
- cancelTicket('提交失败')
- toast((e && e.msg) || e.message || '暂不可领取')
- }
- }
-
- function showAd() {
- return new Promise((resolve, reject) => {
- videoAd
- .show()
- .then(resolve)
- .catch(() => {
- // 首次 show 失败按官方建议先 load 再重试一次
- videoAd
- .load()
- .then(() => videoAd.show())
- .then(resolve)
- .catch(reject)
- })
- })
- }
-
- async function claimReward() {
- if (!currentTicket) return
- const ticket = currentTicket
- currentTicket = ''
- try {
- const res = await pointApi.getAdReward({ ticket })
- const data = res.data || {}
- balance.value = (data.balance && data.balance.points) || balance.value
- if (data.ad) {
- adTask.value = Object.assign({}, adTask.value, data.ad)
- startWait(data.ad.wait_seconds || 0)
- }
- // 同步 store,个人中心与工具页展示的余额跟着变
- userStore.fetchBalance().catch(() => {})
- toast(`点数 +${data.points || adTask.value.points_per_ad}`)
- } catch (e) {
- toast((e && e.msg) || '点数发放失败')
- } finally {
- rewarding.value = false
- }
- }
-
- function cancelTicket(reason) {
- if (!currentTicket) return
- const ticket = currentTicket
- currentTicket = ''
- pointApi.cancelAdTicket({ ticket, reason }).catch(() => {})
- }
-
- function ensureLogin() {
- if (userStore.isLogin) return true
- uni.navigateTo({ url: '/pages/login/login' })
- return false
- }
-
- // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。
- function pointCopy(text, fallback = '') {
- return String(text || fallback).replace(/积分/g, '点数')
- }
-
- async function claimCheckin() {
- if (!ensureLogin()) return
- if (checkinBtnDisabled.value) return
- checkingIn.value = true
- try {
- const res = await pointApi.checkin()
- const data = res.data || {}
- balance.value = (data.balance && data.balance.points) || balance.value
- if (data.checkin) checkinTask.value = Object.assign({}, checkinTask.value, data.checkin)
- userStore.fetchBalance().catch(() => {})
- toast(data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`)
- } catch (e) {
- toast((e && e.msg) || '签到失败,请稍后再试')
- } finally {
- checkingIn.value = false
- }
- }
-
- function goInvite() {
- if (!ensureLogin()) return
- uni.navigateTo({ url: '/pages/invite/invite' })
- }
-
- function goRecharge() {
- uni.navigateTo({ url: '/pages/recharge/recharge' })
- }
-
- function goBill() {
- if (!ensureLogin()) return
- uni.navigateTo({ url: '/pages/bill/bill' })
- }
- </script>
-
- <style lang="scss" scoped>
- .wrap {
- min-height: 100vh;
- padding: 24rpx 28rpx 60rpx;
- background: #f3f7fb;
- box-sizing: border-box;
- }
-
- .head {
- padding: 30rpx;
- border-radius: 26rpx;
- background: linear-gradient(135deg, #1f8cff, #41b9ff);
- display: flex;
- align-items: center;
- gap: 20rpx;
- }
- .head-left { flex: 1; min-width: 0; }
- .kicker { display: block; color: rgba(255, 255, 255, 0.75); font-size: 21rpx; font-weight: 800; }
- .title { display: block; margin-top: 12rpx; color: #fff; font-size: 42rpx; font-weight: 900; }
- .desc {
- display: block;
- margin-top: 12rpx;
- color: rgba(255, 255, 255, 0.85);
- font-size: 22rpx;
- line-height: 1.5;
- }
- .head-actions { display: flex; flex-direction: column; gap: 12rpx; flex-shrink: 0; }
- .head-btn {
- width: 130rpx;
- height: 60rpx;
- border-radius: 999rpx;
- color: #fff;
- font-size: 24rpx;
- font-weight: 800;
- line-height: 60rpx;
- text-align: center;
- background: rgba(255, 255, 255, 0.2);
- }
- .head-btn.primary { color: #1f8cff; background: #fff; }
-
- .skeleton-list { margin-top: 22rpx; }
- .skeleton-row {
- height: 180rpx;
- margin-bottom: 18rpx;
- border-radius: 24rpx;
- background: #e9eef6;
- }
-
- .task-card {
- margin-top: 22rpx;
- padding: 28rpx;
- border-radius: 24rpx;
- background: #fff;
- }
- .task-main { display: flex; align-items: center; gap: 20rpx; }
- .task-icon {
- width: 88rpx;
- height: 88rpx;
- border-radius: 24rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- }
- .task-icon.ad { background: #eef7ff; }
- .task-icon.checkin { background: #eaf5ff; }
- .task-icon.invite { background: #eefaf3; }
- .icon-text { color: #1f8cff; font-size: 36rpx; font-weight: 900; }
- .task-icon.invite .icon-text { color: #17a65a; }
- .task-copy { flex: 1; min-width: 0; }
- .task-title { display: block; color: #172033; font-size: 30rpx; font-weight: 800; }
- .task-desc {
- display: block;
- margin-top: 8rpx;
- color: #7f8896;
- font-size: 23rpx;
- line-height: 1.45;
- }
- .task-progress { display: block; margin-top: 10rpx; color: #1f8cff; font-size: 22rpx; font-weight: 700; }
- .task-progress.success { color: #17a65a; }
- .task-action { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
- .reward-chip {
- padding: 4rpx 16rpx;
- border-radius: 999rpx;
- color: #ff8a00;
- background: #fff5e8;
- font-size: 22rpx;
- font-weight: 800;
- }
- .task-btn {
- min-width: 156rpx;
- height: 68rpx;
- padding: 0 20rpx;
- border-radius: 999rpx;
- color: #fff;
- background: #1f8cff;
- font-size: 25rpx;
- font-weight: 800;
- line-height: 68rpx;
- text-align: center;
- }
- .task-btn.disabled { opacity: 0.5; }
-
- .empty-state {
- margin-top: 60rpx;
- padding: 60rpx 34rpx;
- border-radius: 26rpx;
- background: #fff;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- }
- .empty-title { color: #172033; font-size: 32rpx; font-weight: 900; }
- .empty-desc { margin-top: 12rpx; color: #7f8896; font-size: 25rpx; line-height: 1.5; }
- .empty-action {
- margin-top: 28rpx;
- width: 220rpx;
- height: 72rpx;
- border-radius: 999rpx;
- color: #fff;
- background: #1f8cff;
- font-size: 28rpx;
- font-weight: 800;
- line-height: 72rpx;
- }
-
- .rule-card {
- margin-top: 22rpx;
- padding: 28rpx;
- border-radius: 24rpx;
- background: #fff;
- }
- .rule-title { display: block; margin-bottom: 12rpx; color: #172033; font-size: 28rpx; font-weight: 800; }
- .rule-line {
- display: block;
- margin-top: 8rpx;
- color: #7f8896;
- font-size: 23rpx;
- line-height: 1.6;
- }
- </style>
|