invite.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. <template>
  2. <view class="container">
  3. <!-- 邀请统计 -->
  4. <view class="stats-card">
  5. <view class="stats-item">
  6. <text class="stats-num" v-if="summary">{{ summary.totalInvited || 0 }}</text>
  7. <text class="stats-num" v-else>0</text>
  8. <text class="stats-label">已邀请</text>
  9. </view>
  10. <view class="stats-divider"></view>
  11. <view class="stats-item">
  12. <text class="stats-num" v-if="summary">{{ summary.activeCount || 0 }}</text>
  13. <text class="stats-num" v-else>0</text>
  14. <text class="stats-label">活跃</text>
  15. </view>
  16. <view class="stats-divider"></view>
  17. <view class="stats-item">
  18. <text class="stats-num" v-if="summary">{{ formatPriceWithSymbol(summary.totalEarnings) }}</text>
  19. <text class="stats-num" v-else>{{ formatPriceWithSymbol(0) }}</text>
  20. <text class="stats-label">总收益</text>
  21. </view>
  22. </view>
  23. <!-- 邀请里程碑 -->
  24. <view class="milestone-card" v-if="milestones.length > 0">
  25. <text class="milestone-title">邀请里程碑</text>
  26. <view class="milestone-item" v-for="item in milestones" :key="item.id">
  27. <view class="milestone-header">
  28. <text class="milestone-name">{{ milestoneName(item.milestone) }}</text>
  29. <text class="milestone-reward">+{{ item.rewardPoints || 0 }}积分</text>
  30. </view>
  31. <view class="milestone-bar-wrap">
  32. <view class="milestone-bar-bg">
  33. <view class="milestone-bar-fill" :style="'width:' + milestonePercent(item) + '%'"></view>
  34. </view>
  35. <text class="milestone-count">{{ item.currentCount || 0 }}/{{ item.targetCount || 0 }}</text>
  36. </view>
  37. <view class="milestone-action" v-if="item.status === 'PENDING'">
  38. <button class="claim-btn" @click="claimReward(item)">领取</button>
  39. </view>
  40. <view class="milestone-action" v-if="item.status === 'CLAIMED'">
  41. <text class="claimed-badge">✓ 已领取</text>
  42. </view>
  43. </view>
  44. </view>
  45. <!-- 邀请码展示 -->
  46. <view class="code-card">
  47. <text class="code-title">我的邀请码</text>
  48. <text class="code-value">{{ referralCode || '加载中...' }}</text>
  49. <view class="code-actions">
  50. <button class="action-btn" @click="copyCode">复制邀请码</button>
  51. <button class="action-btn share" @click="shareInvite">分享给好友</button>
  52. </view>
  53. </view>
  54. <!-- 绑定推荐人 -->
  55. <view class="bind-card">
  56. <text class="bind-title">我有推荐人</text>
  57. <view class="bind-row">
  58. <input v-model="bindCode" type="text" placeholder="输入推荐人的邀请码" class="bind-input" />
  59. <button class="bind-btn" @click="bindReferral">绑定</button>
  60. </view>
  61. </view>
  62. <!-- 已邀请列表 -->
  63. <view class="list-section">
  64. <text class="section-title">已邀请好友 ({{ total || 0 }})</text>
  65. <view class="empty-state" v-if="!loading && list.length === 0">
  66. <text>还没有邀请好友,快去分享吧</text>
  67. </view>
  68. <view class="invite-item" v-for="item in list" :key="item.id">
  69. <view class="invite-avatar-wrap">
  70. <text class="invite-avatar">{{ item.avatar ? '' : '👤' }}</text>
  71. </view>
  72. <view class="invite-info">
  73. <text class="invite-name">{{ item.nickname || '好友' }}</text>
  74. <text class="invite-time">{{ formatTime(item.createdAt) }}</text>
  75. </view>
  76. </view>
  77. <view class="loading-state" v-if="loading">
  78. <text>加载中...</text>
  79. </view>
  80. </view>
  81. <SharePoster :show="showPoster" :referralCode="referralCode" :qrCodeBase64="qrCodeBase64" :nickname="userNickname" :userAvatar="userAvatar" @close="showPoster = false" />
  82. </view>
  83. </template>
  84. <script>
  85. import { getReferralCode, bindReferral, getReferralList, getReferralSummary, getInviteQrCode, getInviteMilestones, claimInviteMilestone, extractReferralCode } from '../../utils/api.js'
  86. import SharePoster from '@/components/SharePoster.vue'
  87. import { parseDate } from '../../utils/format.js'
  88. export default {
  89. components: { SharePoster },
  90. data() {
  91. return {
  92. referralCode: '',
  93. bindCode: '',
  94. list: [],
  95. total: 0,
  96. page: 1,
  97. loading: false,
  98. hasMore: true,
  99. summary: null,
  100. showPoster: false,
  101. qrCodeBase64: '',
  102. userAvatar: '',
  103. userNickname: '',
  104. milestones: [],
  105. milestonesLoading: false
  106. }
  107. },
  108. onLoad() {
  109. this.loadCode()
  110. this.loadSummary()
  111. this.loadList()
  112. this.loadMilestones()
  113. },
  114. onReachBottom() {
  115. if (this.hasMore && !this.loading) {
  116. this.page++
  117. this.loadList()
  118. }
  119. },
  120. onShareAppMessage() {
  121. var code = this.referralCode || ''
  122. var path = '/pages/invite/join'
  123. if (code) {
  124. path = path + '?refCode=' + code
  125. }
  126. return {
  127. title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
  128. path: path,
  129. imageUrl: ''
  130. }
  131. },
  132. onShareTimeline() {
  133. var query = ''
  134. var code = this.referralCode || ''
  135. if (code) {
  136. query = 'refCode=' + code
  137. }
  138. return {
  139. title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
  140. query: query,
  141. imageUrl: ''
  142. }
  143. },
  144. methods: {
  145. async loadCode() {
  146. try {
  147. const res = await getReferralCode()
  148. this.referralCode = extractReferralCode(res)
  149. } catch (e) {
  150. console.error('获取邀请码失败', e)
  151. }
  152. },
  153. async loadSummary() {
  154. try {
  155. const res = await getReferralSummary()
  156. if (res.data) {
  157. this.summary = res.data
  158. }
  159. } catch (e) {
  160. console.error('获取邀请统计失败', e)
  161. }
  162. },
  163. async loadList() {
  164. this.loading = true
  165. try {
  166. const res = await getReferralList(this.page)
  167. if (res.data) {
  168. if (res.data.records) {
  169. this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
  170. }
  171. this.total = res.data.total || 0
  172. if (res.data.records) {
  173. this.hasMore = res.data.records.length >= 10
  174. } else {
  175. this.hasMore = false
  176. }
  177. }
  178. } catch (e) {
  179. console.error('获取邀请列表失败', e)
  180. }
  181. this.loading = false
  182. },
  183. copyCode() {
  184. if (!this.referralCode) return
  185. uni.setClipboardData({
  186. data: this.referralCode,
  187. success: function() {
  188. uni.showToast({ title: '已复制邀请码', icon: 'success' })
  189. }
  190. })
  191. },
  192. async shareInvite() {
  193. try {
  194. uni.showLoading({ title: '生成中...' })
  195. var res = await getInviteQrCode()
  196. if (res.data) {
  197. this.qrCodeBase64 = res.data.qrCodeBase64 || ''
  198. this.userNickname = uni.getStorageSync('nickname') || ''
  199. this.userAvatar = uni.getStorageSync('avatar') || ''
  200. this.showPoster = true
  201. }
  202. } catch (e) {
  203. uni.showToast({ title: '生成失败', icon: 'none' })
  204. } finally {
  205. uni.hideLoading()
  206. }
  207. },
  208. async bindReferral() {
  209. if (!this.bindCode) {
  210. uni.showToast({ title: '请输入邀请码', icon: 'none' })
  211. return
  212. }
  213. if (this.bindCode === this.referralCode) {
  214. uni.showToast({ title: '不能绑定自己的邀请码', icon: 'none' })
  215. return
  216. }
  217. try {
  218. await bindReferral(this.bindCode)
  219. uni.showToast({ title: '绑定成功', icon: 'success' })
  220. this.bindCode = ''
  221. } catch (e) {
  222. uni.showToast({ title: e.message || '绑定失败', icon: 'none' })
  223. }
  224. },
  225. async loadMilestones() {
  226. this.milestonesLoading = true
  227. try {
  228. var res = await getInviteMilestones()
  229. if (res.data) {
  230. this.milestones = Array.isArray(res.data) ? res.data : []
  231. }
  232. } catch (e) {
  233. console.error('获取里程碑失败', e)
  234. }
  235. this.milestonesLoading = false
  236. },
  237. async claimReward(item) {
  238. try {
  239. uni.showLoading({ title: '领取中...' })
  240. await claimInviteMilestone(item.milestone)
  241. uni.showToast({ title: '领取成功', icon: 'success' })
  242. this.loadMilestones()
  243. } catch (e) {
  244. uni.showToast({ title: e.message || '领取失败', icon: 'none' })
  245. } finally {
  246. uni.hideLoading()
  247. }
  248. },
  249. milestoneName(key) {
  250. var names = {
  251. INVITE_3: '邀请3人',
  252. INVITE_5: '邀请5人',
  253. INVITE_10: '邀请10人'
  254. }
  255. return names[key] || key
  256. },
  257. milestonePercent(item) {
  258. if (!item.targetCount) return 0
  259. var pct = Math.round((item.currentCount || 0) / item.targetCount * 100)
  260. return Math.min(pct, 100)
  261. },
  262. formatTime(time) {
  263. if (!time) return ''
  264. var d = parseDate(time)
  265. if (!d) return ''
  266. return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
  267. }
  268. }
  269. }
  270. </script>
  271. <style scoped>
  272. .container {
  273. padding: 30rpx;
  274. min-height: 100vh;
  275. background: #FFF7ED;
  276. }
  277. /* ===== 邀请统计 ===== */
  278. .stats-card {
  279. background: #fff;
  280. border-radius: 20rpx;
  281. padding: 32rpx 24rpx;
  282. display: flex;
  283. align-items: center;
  284. margin-bottom: 24rpx;
  285. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  286. }
  287. .stats-item {
  288. flex: 1;
  289. text-align: center;
  290. }
  291. .stats-num {
  292. font-size: 36rpx;
  293. font-weight: bold;
  294. color: #F97316;
  295. display: block;
  296. }
  297. .stats-label {
  298. font-size: 22rpx;
  299. color: #94A3B8;
  300. margin-top: 4rpx;
  301. display: block;
  302. }
  303. .stats-divider {
  304. width: 1rpx;
  305. height: 48rpx;
  306. background: #FED7AA;
  307. }
  308. /* ===== 邀请里程碑 ===== */
  309. .milestone-card {
  310. background: #fff;
  311. border-radius: 20rpx;
  312. padding: 30rpx;
  313. margin-bottom: 24rpx;
  314. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  315. }
  316. .milestone-title {
  317. font-size: 28rpx;
  318. font-weight: 600;
  319. color: #1E293B;
  320. display: block;
  321. margin-bottom: 24rpx;
  322. }
  323. .milestone-item {
  324. margin-bottom: 24rpx;
  325. }
  326. .milestone-item:last-child {
  327. margin-bottom: 0;
  328. }
  329. .milestone-header {
  330. display: flex;
  331. justify-content: space-between;
  332. align-items: center;
  333. margin-bottom: 12rpx;
  334. }
  335. .milestone-name {
  336. font-size: 26rpx;
  337. color: #1E293B;
  338. font-weight: 500;
  339. }
  340. .milestone-reward {
  341. font-size: 22rpx;
  342. color: #F97316;
  343. }
  344. .milestone-bar-wrap {
  345. display: flex;
  346. align-items: center;
  347. }
  348. .milestone-bar-bg {
  349. flex: 1;
  350. height: 20rpx;
  351. background: #FED7AA;
  352. border-radius: 10rpx;
  353. overflow: hidden;
  354. }
  355. .milestone-bar-fill {
  356. height: 100%;
  357. background: linear-gradient(90deg, #F97316, #EA580C);
  358. border-radius: 10rpx;
  359. }
  360. .milestone-count {
  361. font-size: 22rpx;
  362. color: #94A3B8;
  363. margin-left: 12rpx;
  364. min-width: 50rpx;
  365. text-align: right;
  366. }
  367. .milestone-action {
  368. margin-top: 12rpx;
  369. text-align: right;
  370. }
  371. .claim-btn {
  372. background: #F97316;
  373. color: #fff;
  374. font-size: 24rpx;
  375. border-radius: 30rpx;
  376. height: 52rpx;
  377. line-height: 52rpx;
  378. padding: 0 28rpx;
  379. display: inline-block;
  380. }
  381. .claim-btn::after {
  382. border: none;
  383. }
  384. .claim-btn:active {
  385. opacity: 0.9;
  386. }
  387. .claimed-badge {
  388. font-size: 24rpx;
  389. color: #10B981;
  390. }
  391. .code-card {
  392. background: linear-gradient(135deg, #F97316, #EA580C);
  393. border-radius: 20rpx;
  394. padding: 40rpx;
  395. text-align: center;
  396. color: #fff;
  397. margin-bottom: 24rpx;
  398. }
  399. .code-title {
  400. font-size: 24rpx;
  401. opacity: 0.85;
  402. }
  403. .code-value {
  404. font-size: 48rpx;
  405. font-weight: bold;
  406. letter-spacing: 8rpx;
  407. margin: 20rpx 0 30rpx;
  408. display: block;
  409. }
  410. .code-actions {
  411. display: flex;
  412. gap: 20rpx;
  413. }
  414. .action-btn {
  415. flex: 1;
  416. background: rgba(255,255,255,0.2);
  417. color: #fff;
  418. font-size: 26rpx;
  419. border-radius: 40rpx;
  420. height: 64rpx;
  421. line-height: 64rpx;
  422. border: 1rpx solid rgba(255,255,255,0.3);
  423. }
  424. .action-btn::after { border: none; }
  425. .action-btn:active { opacity: 0.8; }
  426. .bind-card {
  427. background: #fff;
  428. border-radius: 20rpx;
  429. padding: 30rpx;
  430. margin-bottom: 24rpx;
  431. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  432. }
  433. .bind-title {
  434. font-size: 28rpx;
  435. font-weight: 600;
  436. color: #1E293B;
  437. display: block;
  438. margin-bottom: 16rpx;
  439. }
  440. .bind-row {
  441. display: flex;
  442. gap: 20rpx;
  443. }
  444. .bind-input {
  445. flex: 1;
  446. border: 1rpx solid #CBD5E1;
  447. border-radius: 12rpx;
  448. padding: 20rpx;
  449. font-size: 28rpx;
  450. }
  451. .bind-btn {
  452. background: #F97316;
  453. color: #fff;
  454. font-size: 26rpx;
  455. border-radius: 12rpx;
  456. height: 64rpx;
  457. line-height: 64rpx;
  458. padding: 0 30rpx;
  459. font-weight: 500;
  460. }
  461. .bind-btn::after { border: none; }
  462. .bind-btn:active { opacity: 0.9; }
  463. .list-section {
  464. background: #fff;
  465. border-radius: 20rpx;
  466. padding: 30rpx;
  467. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  468. }
  469. .section-title {
  470. font-size: 28rpx;
  471. font-weight: 600;
  472. color: #1E293B;
  473. display: block;
  474. margin-bottom: 20rpx;
  475. }
  476. .invite-item {
  477. display: flex;
  478. align-items: center;
  479. padding: 20rpx 0;
  480. border-bottom: 1rpx solid #FED7AA;
  481. }
  482. .invite-item:last-child { border-bottom: none; }
  483. .invite-avatar-wrap {
  484. width: 60rpx;
  485. height: 60rpx;
  486. border-radius: 50%;
  487. background: #FEF3C7;
  488. display: flex;
  489. align-items: center;
  490. justify-content: center;
  491. margin-right: 20rpx;
  492. }
  493. .invite-avatar {
  494. font-size: 32rpx;
  495. }
  496. .invite-info { flex: 1; }
  497. .invite-name {
  498. font-size: 28rpx;
  499. color: #1E293B;
  500. display: block;
  501. }
  502. .invite-time {
  503. font-size: 22rpx;
  504. color: #94A3B8;
  505. margin-top: 4rpx;
  506. }
  507. .empty-state, .loading-state {
  508. text-align: center;
  509. padding: 60rpx;
  510. color: #94A3B8;
  511. font-size: 28rpx;
  512. }
  513. </style>