You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

393 lines
19 KiB

  1. <template>
  2. <div class="min-h-screen bg-[var(--c-f3f7fb)]">
  3. <AppHero
  4. kicker="EARN POINTS"
  5. title="获得点数"
  6. desc="完成下面的任务即可获得点数,点数在小程序、H5 与 PC 端共享,可用于全部 AI 工具。"
  7. >
  8. <template #actions>
  9. <NuxtLink
  10. to="/bill"
  11. class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11"
  12. >
  13. <UIcon name="i-lucide-receipt" class="size-4" />
  14. 点数明细
  15. </NuxtLink>
  16. <NuxtLink
  17. to="/recharge"
  18. class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11"
  19. >
  20. <UIcon name="i-lucide-coins" class="size-4" />
  21. 去充值
  22. </NuxtLink>
  23. </template>
  24. <template #aside>
  25. <div class="w-full overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--c-0758c9)] via-[var(--c-0b8cff)] to-[var(--c-54c8ff)] p-5 text-white shadow-[0_20px_42px_var(--sh-11-126-225-240)] lg:w-[300px] lg:p-6">
  26. <div class="flex items-center gap-2 text-xs font-bold text-white/75">
  27. <UIcon name="i-lucide-gift" class="size-4" />
  28. 我的点数
  29. </div>
  30. <div class="mt-4 text-4xl font-extrabold tabular-nums">{{ balance }}</div>
  31. <div class="mt-2 text-xs text-white/70">点数长期有效,多端共享</div>
  32. <div class="mt-5 flex items-center gap-1.5 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70">
  33. <UIcon name="i-lucide-shield-check" class="size-3.5" />
  34. 任务完成后自动到账
  35. </div>
  36. </div>
  37. </template>
  38. </AppHero>
  39. <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10">
  40. <!-- 未登录 -->
  41. <section
  42. v-if="!isLogin"
  43. class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]"
  44. >
  45. <div class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]">
  46. <UIcon name="i-lucide-gift" class="size-7" />
  47. </div>
  48. <h2 class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后完成任务获得点数</h2>
  49. <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]">
  50. 登录后可以每日签到、邀请好友或扫码看视频获得点数,点数与小程序端共享。
  51. </p>
  52. <button
  53. type="button"
  54. class="mt-7 inline-flex h-11 cursor-pointer items-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
  55. @click="showLogin = true"
  56. >
  57. <UIcon name="i-lucide-log-in" class="size-4" />
  58. 立即登录
  59. </button>
  60. </section>
  61. <template v-else>
  62. <!-- 骨架 -->
  63. <div v-if="loading && !config" class="grid gap-5 lg:grid-cols-2" role="status" aria-label="正在加载任务">
  64. <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" />
  65. <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" />
  66. </div>
  67. <template v-else>
  68. <!-- 到账提示 -->
  69. <Transition name="tip">
  70. <div
  71. v-if="arrivedPoints"
  72. class="mb-5 flex items-center gap-2 rounded-2xl border border-[var(--c-b7ecd0)] bg-[var(--c-edfdf4)] px-4 py-3 text-sm font-bold text-[var(--c-159a54)]"
  73. role="status"
  74. >
  75. <UIcon name="i-lucide-circle-check" class="size-4" />
  76. 点数 +{{ arrivedPoints }} 已到账,当前 {{ balance }} 点
  77. </div>
  78. </Transition>
  79. <!-- 每日签到:低成本任务放在最前,单一主操作可直接完成 -->
  80. <section
  81. v-if="checkinTask.enabled"
  82. class="mb-5 overflow-hidden rounded-3xl border border-[var(--c-dbeafe)] bg-gradient-to-br from-[var(--c-f4faff)] via-[var(--c-ffffff)] to-[var(--c-edfdf4)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:flex lg:items-center lg:gap-6 lg:p-6"
  83. aria-labelledby="checkin-task-title"
  84. >
  85. <div class="flex min-w-0 flex-1 items-start gap-4">
  86. <div class="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]">
  87. <UIcon name="i-lucide-calendar-check-2" class="size-6" />
  88. </div>
  89. <div class="min-w-0">
  90. <div class="flex flex-wrap items-center gap-2">
  91. <h2 id="checkin-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">
  92. {{ pointCopy(checkinTask.title, '每日签到') }}
  93. </h2>
  94. <span class="rounded-full bg-[var(--c-fff5e8)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]">
  95. +{{ checkinTask.points_per_checkin }} / 天
  96. </span>
  97. </div>
  98. <p class="mt-1 text-sm leading-relaxed text-[var(--c-66758a)]">
  99. {{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }}
  100. </p>
  101. <div class="mt-3 flex items-center gap-1.5 text-xs font-bold" :class="checkinTask.checked_in ? 'text-[var(--c-159a54)]' : 'text-[var(--c-0b8cff)]'">
  102. <UIcon :name="checkinTask.checked_in ? 'i-lucide-circle-check' : 'i-lucide-clock-3'" class="size-4" />
  103. {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }}
  104. </div>
  105. </div>
  106. </div>
  107. <button
  108. type="button"
  109. class="mt-5 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[var(--c-d9e3ef)] disabled:shadow-none disabled:opacity-100 lg:mt-0 lg:w-auto"
  110. :disabled="checkinLoading || checkinTask.checked_in"
  111. @click="claimCheckin"
  112. >
  113. <span v-if="checkinLoading" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white motion-reduce:animate-none" />
  114. <UIcon v-else :name="checkinTask.checked_in ? 'i-lucide-check' : 'i-lucide-calendar-plus'" class="size-4" />
  115. {{ checkinLoading ? '签到中...' : (checkinTask.checked_in ? '今日已签到' : '立即签到') }}
  116. </button>
  117. </section>
  118. <div class="grid gap-5 lg:grid-cols-2">
  119. <!-- 看广告:PC 无激励视频,扫码去小程序看 -->
  120. <section
  121. v-if="adTask.enabled"
  122. class="flex h-full flex-col rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:p-6"
  123. aria-labelledby="ad-task-title"
  124. >
  125. <div class="flex items-start justify-between gap-4">
  126. <div class="min-w-0">
  127. <h2 id="ad-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">
  128. {{ pointCopy(adTask.title, '看视频获得点数') }}
  129. </h2>
  130. <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">
  131. {{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }}
  132. </p>
  133. </div>
  134. <span class="shrink-0 rounded-full bg-[var(--c-fff5e8)] px-3 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]">
  135. +{{ adTask.points_per_ad }} / 次
  136. </span>
  137. </div>
  138. <div class="mt-5 flex flex-1 flex-col gap-5 sm:flex-row sm:items-center">
  139. <div class="mx-auto shrink-0 sm:mx-0">
  140. <div class="flex size-[168px] items-center justify-center overflow-hidden rounded-2xl border border-[var(--c-e6edf5)] bg-[var(--c-f8fbff)] p-2">
  141. <img v-if="qrcodeUrl" :src="qrcodeUrl" class="size-full object-contain" alt="小程序码,扫码看视频获得点数">
  142. <span v-else-if="qrLoading" class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/25 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" />
  143. <div v-else class="flex flex-col items-center gap-2 text-center text-xs leading-5 text-[var(--c-8a95a6)]">
  144. <div class="flex size-11 items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]">
  145. <UIcon name="i-lucide-qr-code" class="size-6" />
  146. </div>
  147. <span>点击右侧按钮生成</span>
  148. </div>
  149. </div>
  150. </div>
  151. <div class="flex min-w-0 flex-1 flex-col">
  152. <ol class="space-y-2 text-sm text-[var(--c-5f6f83)]">
  153. <li class="flex gap-2">
  154. <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">1</span>
  155. {{ qrcodeUrl ? '微信扫描左侧小程序码' : '点击下方按钮生成小程序码' }}
  156. </li>
  157. <li class="flex gap-2">
  158. <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">2</span>
  159. 用同一账号登录小程序,完整看完视频
  160. </li>
  161. <li class="flex gap-2">
  162. <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">3</span>
  163. 点数自动到账,本页会自动刷新
  164. </li>
  165. </ol>
  166. <div class="mt-4 text-xs text-[var(--c-8a97a8)]">{{ adProgressText }}</div>
  167. <button
  168. type="button"
  169. class="mt-3 inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-6 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
  170. :disabled="qrLoading"
  171. @click="loadQrcode"
  172. >
  173. <UIcon name="i-lucide-qr-code" class="size-4" />
  174. {{ qrcodeUrl ? '刷新小程序码' : '生成小程序码' }}
  175. </button>
  176. </div>
  177. </div>
  178. </section>
  179. <!-- 邀请好友 -->
  180. <section
  181. v-if="inviteTask.enabled"
  182. class="flex h-full flex-col rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:p-6"
  183. aria-labelledby="invite-task-title"
  184. >
  185. <div class="flex items-start justify-between gap-4">
  186. <div class="min-w-0">
  187. <h2 id="invite-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">邀请好友注册</h2>
  188. <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">
  189. 好友注册成功后,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点
  190. </p>
  191. </div>
  192. <span class="shrink-0 rounded-full bg-[var(--c-edfdf4)] px-3 py-1 text-xs font-extrabold text-[var(--c-159a54)]">
  193. +{{ inviteTask.inviter_points }} / 人
  194. </span>
  195. </div>
  196. <div class="mt-5 grid grid-cols-2 gap-3">
  197. <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4">
  198. <div class="text-xs font-bold text-[var(--c-7b8797)]">成功邀请</div>
  199. <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-172033)]">
  200. {{ inviteTask.stats?.success || 0 }}
  201. </div>
  202. </div>
  203. <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4">
  204. <div class="text-xs font-bold text-[var(--c-7b8797)]">累计获得</div>
  205. <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-0b8cff)]">
  206. {{ inviteTask.stats?.inviter_points || 0 }}
  207. </div>
  208. </div>
  209. </div>
  210. <div class="mt-auto pt-5">
  211. <NuxtLink
  212. to="/invite"
  213. class="flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-opacity hover:opacity-90"
  214. >
  215. <UIcon name="i-lucide-user-plus" class="size-4" />
  216. 去邀请好友
  217. </NuxtLink>
  218. </div>
  219. </section>
  220. </div>
  221. <!-- 规则 -->
  222. <section v-if="ruleLines.length" class="mt-5 rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)] lg:p-6">
  223. <h2 class="text-base font-extrabold text-[var(--c-172033)]">规则说明</h2>
  224. <ul class="mt-3 space-y-2">
  225. <li v-for="(line, index) in ruleLines" :key="line" class="flex items-start gap-3 text-sm leading-relaxed text-[var(--c-5f6f83)]">
  226. <span class="mt-0.5 flex h-5 w-7 shrink-0 items-center justify-center rounded-[14px] bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">
  227. {{ String(index + 1).padStart(2, '0') }}
  228. </span>
  229. {{ line }}
  230. </li>
  231. </ul>
  232. </section>
  233. </template>
  234. </template>
  235. </main>
  236. </div>
  237. </template>
  238. <script setup>
  239. useSeoMeta({
  240. title: '获得点数 - AI在线',
  241. description: '每日签到、邀请好友注册或扫码在小程序看视频,即可获得 AI 在线点数,点数多端共享,可用于全部 AI 工具。',
  242. })
  243. const { post } = useApi()
  244. const { isLogin, showLogin, refreshBalance } = useUser()
  245. const { config, loading, entryEnabled, adTask, inviteTask, checkinTask, load } = useEarnPoints()
  246. const toast = useToast()
  247. const router = useRouter()
  248. const balance = ref(0)
  249. const qrcodeUrl = ref('')
  250. const qrLoading = ref(false)
  251. const checkinLoading = ref(false)
  252. const arrivedPoints = ref(0)
  253. // 扫码后点数在小程序侧到账,PC 只能轮询余额;最多轮 2 分钟,避免无意义的长轮询
  254. const POLL_INTERVAL = 5000
  255. const POLL_LIMIT = 2 * 60 * 1000
  256. let pollTimer = null
  257. let pollStart = 0
  258. const adProgressText = computed(() => {
  259. if (!adTask.value.enabled) return ''
  260. if (adTask.value.daily_limit > 0) {
  261. return `今日 ${adTask.value.today_used || 0}/${adTask.value.daily_limit} 次`
  262. }
  263. return '今日不限次数'
  264. })
  265. const ruleLines = computed(() => {
  266. const lines = []
  267. if (checkinTask.value.enabled && checkinTask.value.rule_text) lines.push(...splitRule(checkinTask.value.rule_text))
  268. if (adTask.value.enabled && adTask.value.rule_text) lines.push(...splitRule(adTask.value.rule_text))
  269. if (inviteTask.value.enabled && inviteTask.value.rule_text) lines.push(...splitRule(inviteTask.value.rule_text))
  270. return lines
  271. })
  272. function splitRule(text) {
  273. return pointCopy(text)
  274. .split(/[\n;;]/)
  275. .map(item => item.trim())
  276. .filter(Boolean)
  277. }
  278. async function init() {
  279. const data = await load(true)
  280. balance.value = Number(data?.balance?.points || 0)
  281. // 所有免费任务都关闭时本页没有意义,直接送去充值页
  282. if (!data?.entry_enabled) router.replace('/recharge')
  283. }
  284. // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。
  285. function pointCopy(text, fallback = '') {
  286. return String(text || fallback).replace(/积分/g, '点数')
  287. }
  288. onMounted(() => {
  289. if (isLogin.value) init()
  290. else load(true)
  291. })
  292. onUnmounted(() => stopPoll())
  293. watch(isLogin, (v) => {
  294. if (v) init()
  295. })
  296. async function claimCheckin() {
  297. if (checkinLoading.value || checkinTask.value.checked_in) return
  298. checkinLoading.value = true
  299. try {
  300. const res = await post('/point/checkin', {})
  301. const data = res.data || {}
  302. if (data.balance) balance.value = Number(data.balance.points || 0)
  303. arrivedPoints.value = Number(data.points || 0)
  304. toast.add({
  305. title: data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`,
  306. color: data.duplicated ? 'neutral' : 'success',
  307. })
  308. await refreshBalance()
  309. await load(true)
  310. } catch (e) {
  311. toast.add({ title: e.message || '签到失败,请稍后再试', color: 'error' })
  312. } finally {
  313. checkinLoading.value = false
  314. }
  315. }
  316. async function loadQrcode() {
  317. if (qrLoading.value) return
  318. qrLoading.value = true
  319. try {
  320. const res = await post('/point/earnqrcode', {})
  321. qrcodeUrl.value = res.data?.qrcode_url || ''
  322. if (!qrcodeUrl.value) throw new Error('小程序码生成失败')
  323. startPoll()
  324. } catch (e) {
  325. toast.add({ title: e.message || '小程序码生成失败', color: 'error' })
  326. } finally {
  327. qrLoading.value = false
  328. }
  329. }
  330. /** 轮询余额:小程序侧看完广告后,这里能自动看到到账 */
  331. function startPoll() {
  332. stopPoll()
  333. pollStart = Date.now()
  334. pollTimer = setInterval(async () => {
  335. if (Date.now() - pollStart > POLL_LIMIT) return stopPoll()
  336. try {
  337. const res = await post('/point/balance', {})
  338. const points = Number(res.data?.points || 0)
  339. if (points > balance.value) {
  340. arrivedPoints.value = points - balance.value
  341. balance.value = points
  342. refreshBalance()
  343. load(true)
  344. }
  345. } catch (e) {
  346. // 单次失败不打断轮询
  347. }
  348. }, POLL_INTERVAL)
  349. }
  350. function stopPoll() {
  351. if (pollTimer) clearInterval(pollTimer)
  352. pollTimer = null
  353. }
  354. </script>
  355. <style scoped>
  356. .tip-enter-active,
  357. .tip-leave-active { transition: opacity 0.2s ease; }
  358. .tip-enter-from,
  359. .tip-leave-to { opacity: 0; }
  360. @media (prefers-reduced-motion: reduce) {
  361. .tip-enter-active,
  362. .tip-leave-active { transition: none; }
  363. }
  364. </style>