您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

368 行
18 KiB

  1. <template>
  2. <div>
  3. <AppHero
  4. kicker="INVITE FRIENDS"
  5. title="邀请好友,一起解锁 AI 创作"
  6. desc="把好用的 AI 工具分享给朋友,好友完成新用户注册后,双方都能获得点数奖励。"
  7. >
  8. <template #aside>
  9. <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-[350px] lg:p-6">
  10. <div class="flex items-center gap-2 text-xs font-bold text-white/75">
  11. <UIcon name="i-lucide-gift" class="size-4" />
  12. 双方奖励
  13. </div>
  14. <div class="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-4 text-center">
  15. <div>
  16. <div class="text-4xl font-extrabold tabular-nums">{{ rewardDisplay(config.inviter_points) }}</div>
  17. <div class="mt-2 text-xs text-white/70">你获得点数</div>
  18. </div>
  19. <div class="h-12 w-px bg-white/20" />
  20. <div>
  21. <div class="text-4xl font-extrabold tabular-nums">{{ rewardDisplay(config.invitee_points) }}</div>
  22. <div class="mt-2 text-xs text-white/70">好友获得点数</div>
  23. </div>
  24. </div>
  25. <div class="mt-5 flex items-center justify-center gap-1.5 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70">
  26. <UIcon name="i-lucide-shield-check" class="size-3.5" />
  27. 好友注册成功后自动发放
  28. </div>
  29. </div>
  30. </template>
  31. </AppHero>
  32. <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10">
  33. <section
  34. v-if="!isLogin"
  35. 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)]"
  36. aria-labelledby="invite-login-title"
  37. >
  38. <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)]">
  39. <UIcon name="i-lucide-user-plus" class="size-7" />
  40. </div>
  41. <h2 id="invite-login-title" class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后生成专属邀请</h2>
  42. <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]">登录后即可复制专属邀请链接、生成微信小程序码并查看邀请奖励。</p>
  43. <button
  44. type="button"
  45. 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"
  46. @click="showLogin = true"
  47. >
  48. <UIcon name="i-lucide-log-in" class="size-4" />
  49. 立即登录
  50. </button>
  51. </section>
  52. <template v-else>
  53. <div v-if="loading && !loaded" class="grid gap-5 lg:grid-cols-12" role="status" aria-label="正在加载邀请信息">
  54. <div class="h-[330px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none lg:col-span-8" />
  55. <div class="h-[330px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none lg:col-span-4" />
  56. </div>
  57. <section
  58. v-else-if="loadError"
  59. class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-16 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]"
  60. role="alert"
  61. >
  62. <UIcon name="i-lucide-circle-alert" class="mx-auto size-10 text-[var(--c-e85f1c)]" />
  63. <h2 class="mt-4 text-lg font-extrabold text-[var(--c-172033)]">邀请信息加载失败</h2>
  64. <p class="mt-2 text-sm text-[var(--c-7b8797)]">{{ loadError }}</p>
  65. <button
  66. type="button"
  67. class="mt-6 cursor-pointer rounded-full bg-[var(--c-eef7ff)] px-6 py-2.5 text-sm font-bold text-[var(--c-0b8cff)]"
  68. @click="loadData"
  69. >
  70. 重新加载
  71. </button>
  72. </section>
  73. <div v-else class="grid gap-5 lg:grid-cols-12 lg:items-start">
  74. <div class="space-y-5 lg:col-span-8">
  75. <section class="overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] shadow-[0_16px_40px_var(--sh-34-68-112-60)]" aria-labelledby="share-title">
  76. <div class="border-b border-[var(--c-edf2f7)] px-5 py-5 sm:px-6">
  77. <div class="flex flex-wrap items-center justify-between gap-3">
  78. <div>
  79. <h2 id="share-title" class="text-lg font-extrabold text-[var(--c-172033)]">分享专属邀请</h2>
  80. <p class="mt-1 text-xs text-[var(--c-8a97a8)]">每位好友仅能绑定一位邀请人,奖励以注册时的活动配置为准。</p>
  81. </div>
  82. <span
  83. class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold"
  84. :class="activityEnabled ? 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)]' : 'bg-[var(--c-f1f5fa)] text-[var(--c-7b8797)]'"
  85. >
  86. <span class="size-1.5 rounded-full" :class="activityEnabled ? 'bg-[var(--c-17a65a)]' : 'bg-[var(--c-9aa5b5)]'" />
  87. {{ activityEnabled ? '活动进行中' : '活动暂未开放' }}
  88. </span>
  89. </div>
  90. </div>
  91. <div class="p-5 sm:p-6">
  92. <div class="rounded-2xl border border-[var(--c-e6edf5)] bg-[var(--c-f8fbff)] p-3.5">
  93. <div class="text-[11px] font-bold text-[var(--c-8a97a8)]">你的邀请链接</div>
  94. <div class="mt-2 flex min-w-0 items-center gap-3">
  95. <div class="min-w-0 flex-1 truncate text-sm font-medium text-[var(--c-4b5b70)]">{{ inviteUrl }}</div>
  96. <button
  97. type="button"
  98. :disabled="!activityEnabled"
  99. class="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-eef7ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] disabled:cursor-not-allowed disabled:text-[var(--c-a0aabb)]"
  100. aria-label="复制邀请链接"
  101. @click="copyLink"
  102. >
  103. <UIcon name="i-lucide-copy" class="size-4" />
  104. </button>
  105. </div>
  106. </div>
  107. <div class="mt-4 grid gap-3 sm:grid-cols-2">
  108. <button
  109. type="button"
  110. :disabled="!activityEnabled"
  111. class="flex h-12 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 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-45"
  112. @click="shareLink"
  113. >
  114. <UIcon name="i-lucide-share-2" class="size-4" />
  115. 分享邀请链接
  116. </button>
  117. <button
  118. type="button"
  119. :disabled="!activityEnabled"
  120. class="flex h-12 cursor-pointer items-center justify-center gap-2 rounded-full border border-[var(--c-dbe9f7)] bg-[var(--c-f5faff)] text-sm font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e8f5ff)] 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-45"
  121. @click="openQr"
  122. >
  123. <UIcon name="i-lucide-qr-code" class="size-4" />
  124. 微信小程序码
  125. </button>
  126. </div>
  127. </div>
  128. </section>
  129. <section class="grid gap-3 sm:grid-cols-2" aria-label="邀请统计">
  130. <article class="rounded-2xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)]">
  131. <div class="flex items-start justify-between">
  132. <div>
  133. <p class="text-xs font-bold text-[var(--c-7b8797)]">成功邀请</p>
  134. <p class="mt-2 text-3xl font-extrabold text-[var(--c-172033)] tabular-nums">{{ stats.success || 0 }}</p>
  135. <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">已完成注册并发放奖励</p>
  136. </div>
  137. <span class="flex size-10 items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]">
  138. <UIcon name="i-lucide-users" class="size-4.5" />
  139. </span>
  140. </div>
  141. </article>
  142. <article class="rounded-2xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)]">
  143. <div class="flex items-start justify-between">
  144. <div>
  145. <p class="text-xs font-bold text-[var(--c-7b8797)]">累计奖励</p>
  146. <p class="mt-2 text-3xl font-extrabold text-[var(--c-159a54)] tabular-nums">+{{ stats.inviter_points || 0 }}</p>
  147. <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">已自动计入可用点数</p>
  148. </div>
  149. <span class="flex size-10 items-center justify-center rounded-xl bg-[var(--c-edfdf4)] text-[var(--c-159a54)]">
  150. <UIcon name="i-lucide-coins" class="size-4.5" />
  151. </span>
  152. </div>
  153. </article>
  154. </section>
  155. <section class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_30px_var(--sh-34-68-112-50)] sm:p-6" aria-labelledby="steps-title">
  156. <h2 id="steps-title" class="text-lg font-extrabold text-[var(--c-172033)]">三步获得奖励</h2>
  157. <div class="mt-5 grid gap-3 sm:grid-cols-3">
  158. <div v-for="step in steps" :key="step.no" class="rounded-2xl bg-[var(--c-f6f9fd)] p-4">
  159. <div class="flex items-center justify-between">
  160. <span class="text-[11px] font-extrabold tracking-[0.12em] text-[var(--c-0b8cff)]">STEP {{ step.no }}</span>
  161. <UIcon :name="step.icon" class="size-4 text-[var(--c-8a97a8)]" />
  162. </div>
  163. <h3 class="mt-4 text-sm font-extrabold text-[var(--c-172033)]">{{ step.title }}</h3>
  164. <p class="mt-1.5 text-xs leading-5 text-[var(--c-7b8797)]">{{ step.desc }}</p>
  165. </div>
  166. </div>
  167. </section>
  168. </div>
  169. <aside class="space-y-5 lg:col-span-4">
  170. <section class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_30px_var(--sh-34-68-112-50)] sm:p-6">
  171. <div class="flex items-center justify-between gap-3">
  172. <h2 class="text-base font-extrabold text-[var(--c-172033)]">最近邀请</h2>
  173. <NuxtLink to="/invite/records" class="inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline">
  174. 全部记录
  175. <UIcon name="i-lucide-chevron-right" class="size-3.5" />
  176. </NuxtLink>
  177. </div>
  178. <div v-if="recentList.length" class="mt-4 divide-y divide-[var(--c-edf2f7)]">
  179. <div v-for="item in recentList" :key="item.id" class="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
  180. <div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-0b8cff)]">
  181. <img v-if="item.avatar" :src="item.avatar" :alt="item.user_name || '好友'" class="size-full object-cover">
  182. <span v-else>{{ getInitial(item.user_name) }}</span>
  183. </div>
  184. <div class="min-w-0 flex-1">
  185. <div class="truncate text-sm font-bold text-[var(--c-172033)]">{{ item.user_name || '好友' }}</div>
  186. <div class="mt-0.5 text-[11px] text-[var(--c-9aa5b5)]">{{ formatTime(item.reward_time || item.add_time) }}</div>
  187. </div>
  188. <span class="shrink-0 text-xs font-extrabold" :class="Number(item.status) === 1 ? 'text-[var(--c-159a54)]' : 'text-[var(--c-8a97a8)]'">
  189. {{ Number(item.status) === 1 ? `+${item.inviter_points || 0}` : item.status_text }}
  190. </span>
  191. </div>
  192. </div>
  193. <div v-else class="py-9 text-center">
  194. <UIcon name="i-lucide-user-round-plus" class="mx-auto size-8 text-[var(--c-b1bbc8)]" />
  195. <p class="mt-3 text-sm font-bold text-[var(--c-66758a)]">还没有邀请记录</p>
  196. <p class="mt-1 text-xs text-[var(--c-9aa5b5)]">分享给第一位好友吧</p>
  197. </div>
  198. </section>
  199. <section class="rounded-3xl bg-gradient-to-br from-[var(--c-f0f8ff)] to-[var(--c-f7fbff)] p-5 ring-1 ring-inset ring-[var(--c-dcecfb)] sm:p-6">
  200. <div class="flex items-center gap-2 text-sm font-extrabold text-[var(--c-172033)]">
  201. <UIcon name="i-lucide-scroll-text" class="size-4 text-[var(--c-0b8cff)]" />
  202. 活动规则
  203. </div>
  204. <p class="mt-3 text-xs leading-6 text-[var(--c-66758a)]">{{ config.rule_text || '好友通过你的邀请完成新用户注册后,你和好友都可获得点数奖励。' }}</p>
  205. <div class="mt-4 border-t border-[var(--c-dcecfb)] pt-4 text-[11px] leading-5 text-[var(--c-8a97a8)]">
  206. 每位新用户仅可触发一次邀请奖励;异常注册或超过每日奖励上限时,本次邀请可能只记录不发放。
  207. </div>
  208. </section>
  209. </aside>
  210. </div>
  211. </template>
  212. </main>
  213. <InviteShareModal
  214. :open="qrOpen"
  215. :qrcode-url="qrcodeUrl"
  216. :invite-url="inviteUrl"
  217. :loading="qrLoading"
  218. @close="qrOpen = false"
  219. @copy="copyLink"
  220. />
  221. </div>
  222. </template>
  223. <script setup>
  224. import dayjs from 'dayjs'
  225. useSeoMeta({
  226. title: '邀请好友 - AI在线',
  227. description: '邀请好友体验 AI 在线,双方领取 AI 创作点数。',
  228. robots: 'noindex, nofollow',
  229. })
  230. const { userInfo, isLogin, showLogin } = useUser()
  231. const { post } = useApi()
  232. const { buildInviteUrl } = useInvite()
  233. const toast = useToast()
  234. const config = ref({})
  235. const stats = ref({})
  236. const recentList = ref([])
  237. const inviteCode = ref('')
  238. const loading = ref(false)
  239. const loaded = ref(false)
  240. const loadError = ref('')
  241. const qrOpen = ref(false)
  242. const qrLoading = ref(false)
  243. const qrcodeUrl = ref('')
  244. const steps = [
  245. { no: '01', title: '分享专属邀请', desc: '复制网页链接,或让好友扫描微信小程序码。', icon: 'i-lucide-share-2' },
  246. { no: '02', title: '好友完成注册', desc: '好友通过邀请落地页登录并完成新用户注册。', icon: 'i-lucide-user-check' },
  247. { no: '03', title: '点数自动到账', desc: '系统核验成功后,双方奖励直接计入点数余额。', icon: 'i-lucide-coins' },
  248. ]
  249. const activityEnabled = computed(() => Number(config.value.status ?? 1) === 1)
  250. const inviteUrl = computed(() => buildInviteUrl(inviteCode.value || userInfo.value.open_id))
  251. function rewardDisplay(value) {
  252. if (!loaded.value) return '—'
  253. return Number(value || 0)
  254. }
  255. async function loadData() {
  256. if (!isLogin.value || loading.value) return
  257. loading.value = true
  258. loadError.value = ''
  259. try {
  260. const [myRes, listRes] = await Promise.all([
  261. post('/invite/my'),
  262. post('/invite/list', { page_no: 1, page_size: 4 }),
  263. ])
  264. config.value = myRes.data?.config || {}
  265. stats.value = myRes.data?.stats || {}
  266. inviteCode.value = myRes.data?.invite_code || ''
  267. recentList.value = listRes.list || []
  268. loaded.value = true
  269. } catch (error) {
  270. loadError.value = error.message || '网络开小差了,请稍后重试。'
  271. } finally {
  272. loading.value = false
  273. }
  274. }
  275. async function copyText(text) {
  276. if (navigator.clipboard && window.isSecureContext) {
  277. await navigator.clipboard.writeText(text)
  278. return
  279. }
  280. const input = document.createElement('textarea')
  281. input.value = text
  282. input.style.position = 'fixed'
  283. input.style.opacity = '0'
  284. document.body.appendChild(input)
  285. input.select()
  286. document.execCommand('copy')
  287. input.remove()
  288. }
  289. async function copyLink() {
  290. if (!activityEnabled.value) return
  291. try {
  292. await copyText(inviteUrl.value)
  293. toast.add({ title: '邀请链接已复制', color: 'success' })
  294. } catch {
  295. toast.add({ title: '复制失败,请手动复制链接', color: 'error' })
  296. }
  297. }
  298. async function shareLink() {
  299. if (!activityEnabled.value) return
  300. if (navigator.share) {
  301. try {
  302. await navigator.share({
  303. title: config.value.share_title || '邀请你体验 AI ONLINE',
  304. text: config.value.share_desc || '登录注册即可领取 AI 创作点数',
  305. url: inviteUrl.value,
  306. })
  307. return
  308. } catch (error) {
  309. if (error?.name === 'AbortError') return
  310. }
  311. }
  312. await copyLink()
  313. }
  314. async function openQr() {
  315. if (!activityEnabled.value) return
  316. qrOpen.value = true
  317. if (qrcodeUrl.value || qrLoading.value) return
  318. qrLoading.value = true
  319. try {
  320. const res = await post('/invite/qrcode')
  321. qrcodeUrl.value = res.data?.qrcode_url || ''
  322. } catch (error) {
  323. toast.add({ title: error.message || '小程序码生成失败', color: 'error' })
  324. } finally {
  325. qrLoading.value = false
  326. }
  327. }
  328. function getInitial(name = '') {
  329. return String(name || '友').slice(0, 1)
  330. }
  331. function formatTime(value) {
  332. if (!value) return ''
  333. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  334. }
  335. onMounted(loadData)
  336. watch(isLogin, (value) => {
  337. if (value) loadData()
  338. })
  339. </script>