Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 

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