Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 

524 righe
15 KiB

  1. <template>
  2. <view class="wrap">
  3. <!-- 余额头卡 -->
  4. <view class="head">
  5. <view class="head-left">
  6. <text class="kicker">获得点数</text>
  7. <text class="title">当前 {{ balance }} 点</text>
  8. <text class="desc">完成下面的任务即可获得点数,点数可用于全部 AI 工具</text>
  9. </view>
  10. <view class="head-actions">
  11. <view class="head-btn" @click="goBill">明细</view>
  12. <view class="head-btn primary" @click="goRecharge">充值</view>
  13. </view>
  14. </view>
  15. <!-- 加载骨架:等待超过 300ms 也有反馈 -->
  16. <view v-if="loading" class="skeleton-list">
  17. <view v-for="i in 3" :key="i" class="skeleton-row"></view>
  18. </view>
  19. <template v-else>
  20. <!-- 任务列表:只渲染已开启的任务 -->
  21. <view v-if="checkinTask.enabled" class="task-card checkin-card">
  22. <view class="task-main">
  23. <view class="task-icon checkin">
  24. <up-icon name="calendar" color="#1f8cff" size="22" />
  25. </view>
  26. <view class="task-copy">
  27. <text class="task-title">{{ pointCopy(checkinTask.title, '每日签到') }}</text>
  28. <text class="task-desc">{{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }}</text>
  29. <text class="task-progress" :class="{ success: checkinTask.checked_in }">
  30. {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }}
  31. </text>
  32. </view>
  33. <view class="task-action">
  34. <view class="reward-chip">+{{ checkinTask.points_per_checkin }}</view>
  35. <view
  36. class="task-btn"
  37. :class="{ disabled: checkinBtnDisabled }"
  38. @click="claimCheckin"
  39. >
  40. {{ checkinBtnText }}
  41. </view>
  42. </view>
  43. </view>
  44. </view>
  45. <view v-if="adTask.enabled" class="task-card">
  46. <view class="task-main">
  47. <view class="task-icon ad">
  48. <text class="icon-text">▶</text>
  49. </view>
  50. <view class="task-copy">
  51. <text class="task-title">{{ pointCopy(adTask.title, '看视频获得点数') }}</text>
  52. <text class="task-desc">{{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }}</text>
  53. <text class="task-progress">{{ adProgressText }}</text>
  54. </view>
  55. <view class="task-action">
  56. <view class="reward-chip">+{{ adTask.points_per_ad }}</view>
  57. <view
  58. class="task-btn"
  59. :class="{ disabled: adBtnDisabled }"
  60. @click="watchAd"
  61. >
  62. {{ adBtnText }}
  63. </view>
  64. </view>
  65. </view>
  66. </view>
  67. <view v-if="inviteTask.enabled" class="task-card">
  68. <view class="task-main">
  69. <view class="task-icon invite">
  70. <text class="icon-text">+</text>
  71. </view>
  72. <view class="task-copy">
  73. <text class="task-title">邀请好友注册</text>
  74. <text class="task-desc">
  75. 好友注册成功,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点
  76. </text>
  77. <text class="task-progress">
  78. 已邀请 {{ inviteTask.stats.success || 0 }} 人 · 累计获得 {{ inviteTask.stats.inviter_points || 0 }} 点
  79. </text>
  80. </view>
  81. <view class="task-action">
  82. <view class="reward-chip">+{{ inviteTask.inviter_points }}</view>
  83. <view class="task-btn" @click="goInvite">去邀请</view>
  84. </view>
  85. </view>
  86. </view>
  87. <!-- 所有任务都未开启:不给空白页,直接引导充值 -->
  88. <view v-if="!checkinTask.enabled && !adTask.enabled && !inviteTask.enabled" class="empty-state">
  89. <text class="empty-title">暂无可做的任务</text>
  90. <text class="empty-desc">活动暂未开启,可以先通过充值获得点数。</text>
  91. <view class="empty-action" @click="goRecharge">去充值</view>
  92. </view>
  93. <!-- 规则 -->
  94. <view v-if="ruleLines.length" class="rule-card">
  95. <text class="rule-title">规则说明</text>
  96. <text v-for="line in ruleLines" :key="line" class="rule-line">· {{ line }}</text>
  97. </view>
  98. </template>
  99. </view>
  100. </template>
  101. <script setup>
  102. import { computed, ref } from 'vue'
  103. import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
  104. import { pointApi } from '@/api/index.js'
  105. import { useUserStore } from '@/store/user.js'
  106. import { useApp } from '@/utils/useApp.js'
  107. const userStore = useUserStore()
  108. const { toast } = useApp()
  109. const loading = ref(true)
  110. const balance = ref(0)
  111. const rewarding = ref(false)
  112. const checkingIn = ref(false)
  113. const waitSeconds = ref(0)
  114. const checkinTask = ref({
  115. enabled: false,
  116. points_per_checkin: 0,
  117. checked_in: false,
  118. can_checkin: false,
  119. title: '',
  120. desc: '',
  121. rule_text: ''
  122. })
  123. const inviteTask = ref({ enabled: false, inviter_points: 0, invitee_points: 0, rule_text: '', stats: {} })
  124. const adTask = ref({
  125. enabled: false,
  126. points_per_ad: 0,
  127. daily_limit: 0,
  128. today_used: 0,
  129. remain: 0,
  130. ad_unit_id: '',
  131. title: '',
  132. desc: '',
  133. rule_text: ''
  134. })
  135. // 激励视频实例:官方约定是页面内单例、不可跨页复用,所以只在本页创建
  136. let videoAd = null
  137. let currentTicket = ''
  138. let waitTimer = 0
  139. const adRemainText = computed(() => {
  140. if (adTask.value.daily_limit <= 0) return '不限次数'
  141. return `今日 ${adTask.value.today_used}/${adTask.value.daily_limit} 次`
  142. })
  143. const adProgressText = computed(() => {
  144. if (!adTask.value.enabled) return ''
  145. if (adTask.value.remain === 0) return '今日次数已用完,明天再来'
  146. return adRemainText.value
  147. })
  148. const adBtnDisabled = computed(
  149. () => rewarding.value || adTask.value.remain === 0 || waitSeconds.value > 0
  150. )
  151. const adBtnText = computed(() => {
  152. if (rewarding.value) return '发放中'
  153. if (adTask.value.remain === 0) return '已领完'
  154. if (waitSeconds.value > 0) return `${waitSeconds.value}s`
  155. return `看视频 +${adTask.value.points_per_ad}`
  156. })
  157. const checkinBtnDisabled = computed(() => checkingIn.value || checkinTask.value.checked_in)
  158. const checkinBtnText = computed(() => {
  159. if (checkingIn.value) return '签到中'
  160. if (checkinTask.value.checked_in) return '已签到'
  161. return '立即签到'
  162. })
  163. const ruleLines = computed(() => {
  164. const lines = []
  165. if (checkinTask.value.enabled && checkinTask.value.rule_text) lines.push(...splitRule(checkinTask.value.rule_text))
  166. if (adTask.value.enabled && adTask.value.rule_text) lines.push(...splitRule(adTask.value.rule_text))
  167. if (inviteTask.value.enabled && inviteTask.value.rule_text) lines.push(...splitRule(inviteTask.value.rule_text))
  168. return lines
  169. })
  170. function splitRule(text) {
  171. return pointCopy(text)
  172. .split(/[\n;;]/)
  173. .map((item) => item.trim())
  174. .filter(Boolean)
  175. }
  176. onLoad(() => {
  177. loadConfig()
  178. })
  179. onShow(() => {
  180. // 从充值/邀请页返回时刷新余额与进度
  181. if (!loading.value) loadConfig(true)
  182. })
  183. onUnload(() => {
  184. clearInterval(waitTimer)
  185. destroyAd()
  186. })
  187. async function loadConfig(silent = false) {
  188. try {
  189. const res = await pointApi.getEarnConfig()
  190. const data = res.data || {}
  191. balance.value = (data.balance && data.balance.points) || 0
  192. checkinTask.value = Object.assign({}, checkinTask.value, data.checkin || {})
  193. inviteTask.value = Object.assign({ stats: {} }, data.invite || {})
  194. adTask.value = Object.assign({}, adTask.value, data.ad || {})
  195. startWait(adTask.value.wait_seconds || 0)
  196. if (adTask.value.enabled) initAd()
  197. } catch (e) {
  198. if (!silent) toast((e && e.msg) || '加载失败')
  199. } finally {
  200. loading.value = false
  201. }
  202. }
  203. /** 冷却倒计时:后端下发剩余秒数,前端只做展示 */
  204. function startWait(seconds) {
  205. clearInterval(waitTimer)
  206. waitSeconds.value = Number(seconds || 0)
  207. if (waitSeconds.value <= 0) return
  208. waitTimer = setInterval(() => {
  209. waitSeconds.value -= 1
  210. if (waitSeconds.value <= 0) clearInterval(waitTimer)
  211. }, 1000)
  212. }
  213. function initAd() {
  214. // #ifdef MP-WEIXIN
  215. if (videoAd || !adTask.value.ad_unit_id) return
  216. if (!wx.createRewardedVideoAd) return
  217. videoAd = wx.createRewardedVideoAd({ adUnitId: adTask.value.ad_unit_id })
  218. videoAd.onError((err) => {
  219. console.log('激励视频加载失败', err)
  220. cancelTicket('广告拉取失败')
  221. rewarding.value = false
  222. toast('广告暂时拉取失败,请稍后再试')
  223. })
  224. videoAd.onClose((res) => {
  225. // 只有完整看完才发奖,中途退出作废凭证且不占次数
  226. if (res && res.isEnded) {
  227. claimReward()
  228. } else {
  229. cancelTicket('未看完')
  230. rewarding.value = false
  231. toast('未看完视频,本次不发放点数')
  232. }
  233. })
  234. // #endif
  235. }
  236. function destroyAd() {
  237. // #ifdef MP-WEIXIN
  238. videoAd = null
  239. // #endif
  240. }
  241. async function watchAd() {
  242. if (!ensureLogin()) return
  243. if (adBtnDisabled.value) return
  244. // #ifndef MP-WEIXIN
  245. toast('请在微信小程序中观看视频获得点数')
  246. return
  247. // #endif
  248. rewarding.value = true
  249. try {
  250. const res = await pointApi.getAdTicket()
  251. const data = res.data || {}
  252. currentTicket = data.ticket || ''
  253. if (data.ad) adTask.value = Object.assign({}, adTask.value, data.ad)
  254. if (!currentTicket) throw new Error('凭证获取失败')
  255. initAd()
  256. if (!videoAd) {
  257. rewarding.value = false
  258. cancelTicket('广告组件不可用')
  259. toast('当前环境不支持激励视频')
  260. return
  261. }
  262. await showAd()
  263. } catch (e) {
  264. rewarding.value = false
  265. cancelTicket('提交失败')
  266. toast((e && e.msg) || e.message || '暂不可领取')
  267. }
  268. }
  269. function showAd() {
  270. return new Promise((resolve, reject) => {
  271. videoAd
  272. .show()
  273. .then(resolve)
  274. .catch(() => {
  275. // 首次 show 失败按官方建议先 load 再重试一次
  276. videoAd
  277. .load()
  278. .then(() => videoAd.show())
  279. .then(resolve)
  280. .catch(reject)
  281. })
  282. })
  283. }
  284. async function claimReward() {
  285. if (!currentTicket) return
  286. const ticket = currentTicket
  287. currentTicket = ''
  288. try {
  289. const res = await pointApi.getAdReward({ ticket })
  290. const data = res.data || {}
  291. balance.value = (data.balance && data.balance.points) || balance.value
  292. if (data.ad) {
  293. adTask.value = Object.assign({}, adTask.value, data.ad)
  294. startWait(data.ad.wait_seconds || 0)
  295. }
  296. // 同步 store,个人中心与工具页展示的余额跟着变
  297. userStore.fetchBalance().catch(() => {})
  298. toast(`点数 +${data.points || adTask.value.points_per_ad}`)
  299. } catch (e) {
  300. toast((e && e.msg) || '点数发放失败')
  301. } finally {
  302. rewarding.value = false
  303. }
  304. }
  305. function cancelTicket(reason) {
  306. if (!currentTicket) return
  307. const ticket = currentTicket
  308. currentTicket = ''
  309. pointApi.cancelAdTicket({ ticket, reason }).catch(() => {})
  310. }
  311. function ensureLogin() {
  312. if (userStore.isLogin) return true
  313. uni.navigateTo({ url: '/pages/login/login' })
  314. return false
  315. }
  316. // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。
  317. function pointCopy(text, fallback = '') {
  318. return String(text || fallback).replace(/积分/g, '点数')
  319. }
  320. async function claimCheckin() {
  321. if (!ensureLogin()) return
  322. if (checkinBtnDisabled.value) return
  323. checkingIn.value = true
  324. try {
  325. const res = await pointApi.checkin()
  326. const data = res.data || {}
  327. balance.value = (data.balance && data.balance.points) || balance.value
  328. if (data.checkin) checkinTask.value = Object.assign({}, checkinTask.value, data.checkin)
  329. userStore.fetchBalance().catch(() => {})
  330. toast(data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`)
  331. } catch (e) {
  332. toast((e && e.msg) || '签到失败,请稍后再试')
  333. } finally {
  334. checkingIn.value = false
  335. }
  336. }
  337. function goInvite() {
  338. if (!ensureLogin()) return
  339. uni.navigateTo({ url: '/pages/invite/invite' })
  340. }
  341. function goRecharge() {
  342. uni.navigateTo({ url: '/pages/recharge/recharge' })
  343. }
  344. function goBill() {
  345. if (!ensureLogin()) return
  346. uni.navigateTo({ url: '/pages/bill/bill' })
  347. }
  348. </script>
  349. <style lang="scss" scoped>
  350. .wrap {
  351. min-height: 100vh;
  352. padding: 24rpx 28rpx 60rpx;
  353. background: #f3f7fb;
  354. box-sizing: border-box;
  355. }
  356. .head {
  357. padding: 30rpx;
  358. border-radius: 26rpx;
  359. background: linear-gradient(135deg, #1f8cff, #41b9ff);
  360. display: flex;
  361. align-items: center;
  362. gap: 20rpx;
  363. }
  364. .head-left { flex: 1; min-width: 0; }
  365. .kicker { display: block; color: rgba(255, 255, 255, 0.75); font-size: 21rpx; font-weight: 800; }
  366. .title { display: block; margin-top: 12rpx; color: #fff; font-size: 42rpx; font-weight: 900; }
  367. .desc {
  368. display: block;
  369. margin-top: 12rpx;
  370. color: rgba(255, 255, 255, 0.85);
  371. font-size: 22rpx;
  372. line-height: 1.5;
  373. }
  374. .head-actions { display: flex; flex-direction: column; gap: 12rpx; flex-shrink: 0; }
  375. .head-btn {
  376. width: 130rpx;
  377. height: 60rpx;
  378. border-radius: 999rpx;
  379. color: #fff;
  380. font-size: 24rpx;
  381. font-weight: 800;
  382. line-height: 60rpx;
  383. text-align: center;
  384. background: rgba(255, 255, 255, 0.2);
  385. }
  386. .head-btn.primary { color: #1f8cff; background: #fff; }
  387. .skeleton-list { margin-top: 22rpx; }
  388. .skeleton-row {
  389. height: 180rpx;
  390. margin-bottom: 18rpx;
  391. border-radius: 24rpx;
  392. background: #e9eef6;
  393. }
  394. .task-card {
  395. margin-top: 22rpx;
  396. padding: 28rpx;
  397. border-radius: 24rpx;
  398. background: #fff;
  399. }
  400. .task-main { display: flex; align-items: center; gap: 20rpx; }
  401. .task-icon {
  402. width: 88rpx;
  403. height: 88rpx;
  404. border-radius: 24rpx;
  405. display: flex;
  406. align-items: center;
  407. justify-content: center;
  408. flex-shrink: 0;
  409. }
  410. .task-icon.ad { background: #eef7ff; }
  411. .task-icon.checkin { background: #eaf5ff; }
  412. .task-icon.invite { background: #eefaf3; }
  413. .icon-text { color: #1f8cff; font-size: 36rpx; font-weight: 900; }
  414. .task-icon.invite .icon-text { color: #17a65a; }
  415. .task-copy { flex: 1; min-width: 0; }
  416. .task-title { display: block; color: #172033; font-size: 30rpx; font-weight: 800; }
  417. .task-desc {
  418. display: block;
  419. margin-top: 8rpx;
  420. color: #7f8896;
  421. font-size: 23rpx;
  422. line-height: 1.45;
  423. }
  424. .task-progress { display: block; margin-top: 10rpx; color: #1f8cff; font-size: 22rpx; font-weight: 700; }
  425. .task-progress.success { color: #17a65a; }
  426. .task-action { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
  427. .reward-chip {
  428. padding: 4rpx 16rpx;
  429. border-radius: 999rpx;
  430. color: #ff8a00;
  431. background: #fff5e8;
  432. font-size: 22rpx;
  433. font-weight: 800;
  434. }
  435. .task-btn {
  436. min-width: 156rpx;
  437. height: 68rpx;
  438. padding: 0 20rpx;
  439. border-radius: 999rpx;
  440. color: #fff;
  441. background: #1f8cff;
  442. font-size: 25rpx;
  443. font-weight: 800;
  444. line-height: 68rpx;
  445. text-align: center;
  446. }
  447. .task-btn.disabled { opacity: 0.5; }
  448. .empty-state {
  449. margin-top: 60rpx;
  450. padding: 60rpx 34rpx;
  451. border-radius: 26rpx;
  452. background: #fff;
  453. display: flex;
  454. flex-direction: column;
  455. align-items: center;
  456. text-align: center;
  457. }
  458. .empty-title { color: #172033; font-size: 32rpx; font-weight: 900; }
  459. .empty-desc { margin-top: 12rpx; color: #7f8896; font-size: 25rpx; line-height: 1.5; }
  460. .empty-action {
  461. margin-top: 28rpx;
  462. width: 220rpx;
  463. height: 72rpx;
  464. border-radius: 999rpx;
  465. color: #fff;
  466. background: #1f8cff;
  467. font-size: 28rpx;
  468. font-weight: 800;
  469. line-height: 72rpx;
  470. }
  471. .rule-card {
  472. margin-top: 22rpx;
  473. padding: 28rpx;
  474. border-radius: 24rpx;
  475. background: #fff;
  476. }
  477. .rule-title { display: block; margin-bottom: 12rpx; color: #172033; font-size: 28rpx; font-weight: 800; }
  478. .rule-line {
  479. display: block;
  480. margin-top: 8rpx;
  481. color: #7f8896;
  482. font-size: 23rpx;
  483. line-height: 1.6;
  484. }
  485. </style>