|
- <template>
- <div class="min-h-screen bg-[var(--c-f3f7fb)]">
- <AppHero
- kicker="EARN POINTS"
- title="获得点数"
- desc="完成下面的任务即可获得点数,点数在小程序、H5 与 PC 端共享,可用于全部 AI 工具。"
- >
- <template #actions>
- <NuxtLink
- to="/bill"
- 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"
- >
- <UIcon name="i-lucide-receipt" class="size-4" />
- 点数明细
- </NuxtLink>
- <NuxtLink
- to="/recharge"
- 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"
- >
- <UIcon name="i-lucide-coins" class="size-4" />
- 去充值
- </NuxtLink>
- </template>
- <template #aside>
- <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">
- <div class="flex items-center gap-2 text-xs font-bold text-white/75">
- <UIcon name="i-lucide-gift" class="size-4" />
- 我的点数
- </div>
- <div class="mt-4 text-4xl font-extrabold tabular-nums">{{ balance }}</div>
- <div class="mt-2 text-xs text-white/70">点数长期有效,多端共享</div>
- <div class="mt-5 flex items-center gap-1.5 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70">
- <UIcon name="i-lucide-shield-check" class="size-3.5" />
- 任务完成后自动到账
- </div>
- </div>
- </template>
- </AppHero>
-
- <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10">
- <!-- 未登录 -->
- <section
- v-if="!isLogin"
- 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)]"
- >
- <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)]">
- <UIcon name="i-lucide-gift" class="size-7" />
- </div>
- <h2 class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后完成任务获得点数</h2>
- <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]">
- 登录后可以每日签到、邀请好友或扫码看视频获得点数,点数与小程序端共享。
- </p>
- <button
- type="button"
- 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"
- @click="showLogin = true"
- >
- <UIcon name="i-lucide-log-in" class="size-4" />
- 立即登录
- </button>
- </section>
-
- <template v-else>
- <!-- 骨架 -->
- <div v-if="loading && !config" class="grid gap-5 lg:grid-cols-2" role="status" aria-label="正在加载任务">
- <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" />
- <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" />
- </div>
-
- <template v-else>
- <!-- 到账提示 -->
- <Transition name="tip">
- <div
- v-if="arrivedPoints"
- 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)]"
- role="status"
- >
- <UIcon name="i-lucide-circle-check" class="size-4" />
- 点数 +{{ arrivedPoints }} 已到账,当前 {{ balance }} 点
- </div>
- </Transition>
-
- <!-- 每日签到:低成本任务放在最前,单一主操作可直接完成 -->
- <section
- v-if="checkinTask.enabled"
- 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"
- aria-labelledby="checkin-task-title"
- >
- <div class="flex min-w-0 flex-1 items-start gap-4">
- <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)]">
- <UIcon name="i-lucide-calendar-check-2" class="size-6" />
- </div>
- <div class="min-w-0">
- <div class="flex flex-wrap items-center gap-2">
- <h2 id="checkin-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">
- {{ pointCopy(checkinTask.title, '每日签到') }}
- </h2>
- <span class="rounded-full bg-[var(--c-fff5e8)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]">
- +{{ checkinTask.points_per_checkin }} / 天
- </span>
- </div>
- <p class="mt-1 text-sm leading-relaxed text-[var(--c-66758a)]">
- {{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }}
- </p>
- <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)]'">
- <UIcon :name="checkinTask.checked_in ? 'i-lucide-circle-check' : 'i-lucide-clock-3'" class="size-4" />
- {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }}
- </div>
- </div>
- </div>
- <button
- type="button"
- 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"
- :disabled="checkinLoading || checkinTask.checked_in"
- @click="claimCheckin"
- >
- <span v-if="checkinLoading" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white motion-reduce:animate-none" />
- <UIcon v-else :name="checkinTask.checked_in ? 'i-lucide-check' : 'i-lucide-calendar-plus'" class="size-4" />
- {{ checkinLoading ? '签到中...' : (checkinTask.checked_in ? '今日已签到' : '立即签到') }}
- </button>
- </section>
-
- <div class="grid gap-5 lg:grid-cols-2">
- <!-- 看广告:PC 无激励视频,扫码去小程序看 -->
- <section
- v-if="adTask.enabled"
- 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"
- aria-labelledby="ad-task-title"
- >
- <div class="flex items-start justify-between gap-4">
- <div class="min-w-0">
- <h2 id="ad-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">
- {{ pointCopy(adTask.title, '看视频获得点数') }}
- </h2>
- <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">
- {{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }}
- </p>
- </div>
- <span class="shrink-0 rounded-full bg-[var(--c-fff5e8)] px-3 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]">
- +{{ adTask.points_per_ad }} / 次
- </span>
- </div>
-
- <div class="mt-5 flex flex-1 flex-col gap-5 sm:flex-row sm:items-center">
- <div class="mx-auto shrink-0 sm:mx-0">
- <div class="flex size-[168px] items-center justify-center overflow-hidden rounded-2xl border border-[var(--c-e6edf5)] bg-[var(--c-f8fbff)] p-2">
- <img v-if="qrcodeUrl" :src="qrcodeUrl" class="size-full object-contain" alt="小程序码,扫码看视频获得点数">
- <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" />
- <div v-else class="flex flex-col items-center gap-2 text-center text-xs leading-5 text-[var(--c-8a95a6)]">
- <div class="flex size-11 items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]">
- <UIcon name="i-lucide-qr-code" class="size-6" />
- </div>
- <span>点击右侧按钮生成</span>
- </div>
- </div>
- </div>
-
- <div class="flex min-w-0 flex-1 flex-col">
- <ol class="space-y-2 text-sm text-[var(--c-5f6f83)]">
- <li class="flex gap-2">
- <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>
- {{ qrcodeUrl ? '微信扫描左侧小程序码' : '点击下方按钮生成小程序码' }}
- </li>
- <li class="flex gap-2">
- <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>
- 用同一账号登录小程序,完整看完视频
- </li>
- <li class="flex gap-2">
- <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>
- 点数自动到账,本页会自动刷新
- </li>
- </ol>
- <div class="mt-4 text-xs text-[var(--c-8a97a8)]">{{ adProgressText }}</div>
- <button
- type="button"
- 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"
- :disabled="qrLoading"
- @click="loadQrcode"
- >
- <UIcon name="i-lucide-qr-code" class="size-4" />
- {{ qrcodeUrl ? '刷新小程序码' : '生成小程序码' }}
- </button>
- </div>
- </div>
- </section>
-
- <!-- 邀请好友 -->
- <section
- v-if="inviteTask.enabled"
- 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"
- aria-labelledby="invite-task-title"
- >
- <div class="flex items-start justify-between gap-4">
- <div class="min-w-0">
- <h2 id="invite-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">邀请好友注册</h2>
- <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">
- 好友注册成功后,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点
- </p>
- </div>
- <span class="shrink-0 rounded-full bg-[var(--c-edfdf4)] px-3 py-1 text-xs font-extrabold text-[var(--c-159a54)]">
- +{{ inviteTask.inviter_points }} / 人
- </span>
- </div>
-
- <div class="mt-5 grid grid-cols-2 gap-3">
- <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4">
- <div class="text-xs font-bold text-[var(--c-7b8797)]">成功邀请</div>
- <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-172033)]">
- {{ inviteTask.stats?.success || 0 }}
- </div>
- </div>
- <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4">
- <div class="text-xs font-bold text-[var(--c-7b8797)]">累计获得</div>
- <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-0b8cff)]">
- {{ inviteTask.stats?.inviter_points || 0 }}
- </div>
- </div>
- </div>
-
- <div class="mt-auto pt-5">
- <NuxtLink
- to="/invite"
- 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"
- >
- <UIcon name="i-lucide-user-plus" class="size-4" />
- 去邀请好友
- </NuxtLink>
- </div>
- </section>
- </div>
-
- <!-- 规则 -->
- <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">
- <h2 class="text-base font-extrabold text-[var(--c-172033)]">规则说明</h2>
- <ul class="mt-3 space-y-2">
- <li v-for="(line, index) in ruleLines" :key="line" class="flex items-start gap-3 text-sm leading-relaxed text-[var(--c-5f6f83)]">
- <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)]">
- {{ String(index + 1).padStart(2, '0') }}
- </span>
- {{ line }}
- </li>
- </ul>
- </section>
- </template>
- </template>
- </main>
- </div>
- </template>
-
- <script setup>
- useSeoMeta({
- title: '获得点数 - AI在线',
- description: '每日签到、邀请好友注册或扫码在小程序看视频,即可获得 AI 在线点数,点数多端共享,可用于全部 AI 工具。',
- })
-
- const { post } = useApi()
- const { isLogin, showLogin, refreshBalance } = useUser()
- const { config, loading, entryEnabled, adTask, inviteTask, checkinTask, load } = useEarnPoints()
- const toast = useToast()
- const router = useRouter()
-
- const balance = ref(0)
- const qrcodeUrl = ref('')
- const qrLoading = ref(false)
- const checkinLoading = ref(false)
- const arrivedPoints = ref(0)
-
- // 扫码后点数在小程序侧到账,PC 只能轮询余额;最多轮 2 分钟,避免无意义的长轮询
- const POLL_INTERVAL = 5000
- const POLL_LIMIT = 2 * 60 * 1000
- let pollTimer = null
- let pollStart = 0
-
- const adProgressText = computed(() => {
- if (!adTask.value.enabled) return ''
- if (adTask.value.daily_limit > 0) {
- return `今日 ${adTask.value.today_used || 0}/${adTask.value.daily_limit} 次`
- }
- 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)
- }
-
- async function init() {
- const data = await load(true)
- balance.value = Number(data?.balance?.points || 0)
- // 所有免费任务都关闭时本页没有意义,直接送去充值页
- if (!data?.entry_enabled) router.replace('/recharge')
- }
-
- // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。
- function pointCopy(text, fallback = '') {
- return String(text || fallback).replace(/积分/g, '点数')
- }
-
- onMounted(() => {
- if (isLogin.value) init()
- else load(true)
- })
- onUnmounted(() => stopPoll())
-
- watch(isLogin, (v) => {
- if (v) init()
- })
-
- async function claimCheckin() {
- if (checkinLoading.value || checkinTask.value.checked_in) return
- checkinLoading.value = true
- try {
- const res = await post('/point/checkin', {})
- const data = res.data || {}
- if (data.balance) balance.value = Number(data.balance.points || 0)
- arrivedPoints.value = Number(data.points || 0)
- toast.add({
- title: data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`,
- color: data.duplicated ? 'neutral' : 'success',
- })
- await refreshBalance()
- await load(true)
- } catch (e) {
- toast.add({ title: e.message || '签到失败,请稍后再试', color: 'error' })
- } finally {
- checkinLoading.value = false
- }
- }
-
- async function loadQrcode() {
- if (qrLoading.value) return
- qrLoading.value = true
- try {
- const res = await post('/point/earnqrcode', {})
- qrcodeUrl.value = res.data?.qrcode_url || ''
- if (!qrcodeUrl.value) throw new Error('小程序码生成失败')
- startPoll()
- } catch (e) {
- toast.add({ title: e.message || '小程序码生成失败', color: 'error' })
- } finally {
- qrLoading.value = false
- }
- }
-
- /** 轮询余额:小程序侧看完广告后,这里能自动看到到账 */
- function startPoll() {
- stopPoll()
- pollStart = Date.now()
- pollTimer = setInterval(async () => {
- if (Date.now() - pollStart > POLL_LIMIT) return stopPoll()
- try {
- const res = await post('/point/balance', {})
- const points = Number(res.data?.points || 0)
- if (points > balance.value) {
- arrivedPoints.value = points - balance.value
- balance.value = points
- refreshBalance()
- load(true)
- }
- } catch (e) {
- // 单次失败不打断轮询
- }
- }, POLL_INTERVAL)
- }
-
- function stopPoll() {
- if (pollTimer) clearInterval(pollTimer)
- pollTimer = null
- }
- </script>
-
- <style scoped>
- .tip-enter-active,
- .tip-leave-active { transition: opacity 0.2s ease; }
- .tip-enter-from,
- .tip-leave-to { opacity: 0; }
-
- @media (prefers-reduced-motion: reduce) {
- .tip-enter-active,
- .tip-leave-active { transition: none; }
- }
- </style>
|