invite.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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 } 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 + '?inviteCode=' + code
  125. }
  126. return {
  127. title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
  128. path: path,
  129. imageUrl: ''
  130. }
  131. },
  132. methods: {
  133. async loadCode() {
  134. try {
  135. const res = await getReferralCode()
  136. if (res.data) {
  137. this.referralCode = res.data.referralCode || res.data.code || ''
  138. }
  139. } catch (e) {
  140. console.error('获取邀请码失败', e)
  141. }
  142. },
  143. async loadSummary() {
  144. try {
  145. const res = await getReferralSummary()
  146. if (res.data) {
  147. this.summary = res.data
  148. }
  149. } catch (e) {
  150. console.error('获取邀请统计失败', e)
  151. }
  152. },
  153. async loadList() {
  154. this.loading = true
  155. try {
  156. const res = await getReferralList(this.page)
  157. if (res.data) {
  158. if (res.data.records) {
  159. this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
  160. }
  161. this.total = res.data.total || 0
  162. if (res.data.records) {
  163. this.hasMore = res.data.records.length >= 10
  164. } else {
  165. this.hasMore = false
  166. }
  167. }
  168. } catch (e) {
  169. console.error('获取邀请列表失败', e)
  170. }
  171. this.loading = false
  172. },
  173. copyCode() {
  174. if (!this.referralCode) return
  175. uni.setClipboardData({
  176. data: this.referralCode,
  177. success: function() {
  178. uni.showToast({ title: '已复制邀请码', icon: 'success' })
  179. }
  180. })
  181. },
  182. async shareInvite() {
  183. try {
  184. uni.showLoading({ title: '生成中...' })
  185. var res = await getInviteQrCode()
  186. if (res.data) {
  187. this.qrCodeBase64 = res.data.qrCodeBase64 || ''
  188. this.userNickname = uni.getStorageSync('nickname') || ''
  189. this.userAvatar = uni.getStorageSync('avatar') || ''
  190. this.showPoster = true
  191. }
  192. } catch (e) {
  193. uni.showToast({ title: '生成失败', icon: 'none' })
  194. } finally {
  195. uni.hideLoading()
  196. }
  197. },
  198. async bindReferral() {
  199. if (!this.bindCode) {
  200. uni.showToast({ title: '请输入邀请码', icon: 'none' })
  201. return
  202. }
  203. if (this.bindCode === this.referralCode) {
  204. uni.showToast({ title: '不能绑定自己的邀请码', icon: 'none' })
  205. return
  206. }
  207. try {
  208. await bindReferral(this.bindCode)
  209. uni.showToast({ title: '绑定成功', icon: 'success' })
  210. this.bindCode = ''
  211. } catch (e) {
  212. uni.showToast({ title: e.message || '绑定失败', icon: 'none' })
  213. }
  214. },
  215. async loadMilestones() {
  216. this.milestonesLoading = true
  217. try {
  218. var res = await getInviteMilestones()
  219. if (res.data) {
  220. this.milestones = Array.isArray(res.data) ? res.data : []
  221. }
  222. } catch (e) {
  223. console.error('获取里程碑失败', e)
  224. }
  225. this.milestonesLoading = false
  226. },
  227. async claimReward(item) {
  228. try {
  229. uni.showLoading({ title: '领取中...' })
  230. await claimInviteMilestone(item.milestone)
  231. uni.showToast({ title: '领取成功', icon: 'success' })
  232. this.loadMilestones()
  233. } catch (e) {
  234. uni.showToast({ title: e.message || '领取失败', icon: 'none' })
  235. } finally {
  236. uni.hideLoading()
  237. }
  238. },
  239. milestoneName(key) {
  240. var names = {
  241. INVITE_3: '邀请3人',
  242. INVITE_5: '邀请5人',
  243. INVITE_10: '邀请10人'
  244. }
  245. return names[key] || key
  246. },
  247. milestonePercent(item) {
  248. if (!item.targetCount) return 0
  249. var pct = Math.round((item.currentCount || 0) / item.targetCount * 100)
  250. return Math.min(pct, 100)
  251. },
  252. formatTime(time) {
  253. if (!time) return ''
  254. var d = parseDate(time)
  255. if (!d) return ''
  256. return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
  257. }
  258. }
  259. }
  260. </script>
  261. <style scoped>
  262. .container {
  263. padding: 30rpx;
  264. min-height: 100vh;
  265. background: #FFF7ED;
  266. }
  267. /* ===== 邀请统计 ===== */
  268. .stats-card {
  269. background: #fff;
  270. border-radius: 20rpx;
  271. padding: 32rpx 24rpx;
  272. display: flex;
  273. align-items: center;
  274. margin-bottom: 24rpx;
  275. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  276. }
  277. .stats-item {
  278. flex: 1;
  279. text-align: center;
  280. }
  281. .stats-num {
  282. font-size: 36rpx;
  283. font-weight: bold;
  284. color: #F97316;
  285. display: block;
  286. }
  287. .stats-label {
  288. font-size: 22rpx;
  289. color: #94A3B8;
  290. margin-top: 4rpx;
  291. display: block;
  292. }
  293. .stats-divider {
  294. width: 1rpx;
  295. height: 48rpx;
  296. background: #FED7AA;
  297. }
  298. /* ===== 邀请里程碑 ===== */
  299. .milestone-card {
  300. background: #fff;
  301. border-radius: 20rpx;
  302. padding: 30rpx;
  303. margin-bottom: 24rpx;
  304. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  305. }
  306. .milestone-title {
  307. font-size: 28rpx;
  308. font-weight: 600;
  309. color: #1E293B;
  310. display: block;
  311. margin-bottom: 24rpx;
  312. }
  313. .milestone-item {
  314. margin-bottom: 24rpx;
  315. }
  316. .milestone-item:last-child {
  317. margin-bottom: 0;
  318. }
  319. .milestone-header {
  320. display: flex;
  321. justify-content: space-between;
  322. align-items: center;
  323. margin-bottom: 12rpx;
  324. }
  325. .milestone-name {
  326. font-size: 26rpx;
  327. color: #1E293B;
  328. font-weight: 500;
  329. }
  330. .milestone-reward {
  331. font-size: 22rpx;
  332. color: #F97316;
  333. }
  334. .milestone-bar-wrap {
  335. display: flex;
  336. align-items: center;
  337. }
  338. .milestone-bar-bg {
  339. flex: 1;
  340. height: 20rpx;
  341. background: #FED7AA;
  342. border-radius: 10rpx;
  343. overflow: hidden;
  344. }
  345. .milestone-bar-fill {
  346. height: 100%;
  347. background: linear-gradient(90deg, #F97316, #EA580C);
  348. border-radius: 10rpx;
  349. }
  350. .milestone-count {
  351. font-size: 22rpx;
  352. color: #94A3B8;
  353. margin-left: 12rpx;
  354. min-width: 50rpx;
  355. text-align: right;
  356. }
  357. .milestone-action {
  358. margin-top: 12rpx;
  359. text-align: right;
  360. }
  361. .claim-btn {
  362. background: #F97316;
  363. color: #fff;
  364. font-size: 24rpx;
  365. border-radius: 30rpx;
  366. height: 52rpx;
  367. line-height: 52rpx;
  368. padding: 0 28rpx;
  369. display: inline-block;
  370. }
  371. .claim-btn::after {
  372. border: none;
  373. }
  374. .claim-btn:active {
  375. opacity: 0.9;
  376. }
  377. .claimed-badge {
  378. font-size: 24rpx;
  379. color: #10B981;
  380. }
  381. .code-card {
  382. background: linear-gradient(135deg, #F97316, #EA580C);
  383. border-radius: 20rpx;
  384. padding: 40rpx;
  385. text-align: center;
  386. color: #fff;
  387. margin-bottom: 24rpx;
  388. }
  389. .code-title {
  390. font-size: 24rpx;
  391. opacity: 0.85;
  392. }
  393. .code-value {
  394. font-size: 48rpx;
  395. font-weight: bold;
  396. letter-spacing: 8rpx;
  397. margin: 20rpx 0 30rpx;
  398. display: block;
  399. }
  400. .code-actions {
  401. display: flex;
  402. gap: 20rpx;
  403. }
  404. .action-btn {
  405. flex: 1;
  406. background: rgba(255,255,255,0.2);
  407. color: #fff;
  408. font-size: 26rpx;
  409. border-radius: 40rpx;
  410. height: 64rpx;
  411. line-height: 64rpx;
  412. border: 1rpx solid rgba(255,255,255,0.3);
  413. }
  414. .action-btn::after { border: none; }
  415. .action-btn:active { opacity: 0.8; }
  416. .bind-card {
  417. background: #fff;
  418. border-radius: 20rpx;
  419. padding: 30rpx;
  420. margin-bottom: 24rpx;
  421. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  422. }
  423. .bind-title {
  424. font-size: 28rpx;
  425. font-weight: 600;
  426. color: #1E293B;
  427. display: block;
  428. margin-bottom: 16rpx;
  429. }
  430. .bind-row {
  431. display: flex;
  432. gap: 20rpx;
  433. }
  434. .bind-input {
  435. flex: 1;
  436. border: 1rpx solid #CBD5E1;
  437. border-radius: 12rpx;
  438. padding: 20rpx;
  439. font-size: 28rpx;
  440. }
  441. .bind-btn {
  442. background: #F97316;
  443. color: #fff;
  444. font-size: 26rpx;
  445. border-radius: 12rpx;
  446. height: 64rpx;
  447. line-height: 64rpx;
  448. padding: 0 30rpx;
  449. font-weight: 500;
  450. }
  451. .bind-btn::after { border: none; }
  452. .bind-btn:active { opacity: 0.9; }
  453. .list-section {
  454. background: #fff;
  455. border-radius: 20rpx;
  456. padding: 30rpx;
  457. box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
  458. }
  459. .section-title {
  460. font-size: 28rpx;
  461. font-weight: 600;
  462. color: #1E293B;
  463. display: block;
  464. margin-bottom: 20rpx;
  465. }
  466. .invite-item {
  467. display: flex;
  468. align-items: center;
  469. padding: 20rpx 0;
  470. border-bottom: 1rpx solid #FED7AA;
  471. }
  472. .invite-item:last-child { border-bottom: none; }
  473. .invite-avatar-wrap {
  474. width: 60rpx;
  475. height: 60rpx;
  476. border-radius: 50%;
  477. background: #FEF3C7;
  478. display: flex;
  479. align-items: center;
  480. justify-content: center;
  481. margin-right: 20rpx;
  482. }
  483. .invite-avatar {
  484. font-size: 32rpx;
  485. }
  486. .invite-info { flex: 1; }
  487. .invite-name {
  488. font-size: 28rpx;
  489. color: #1E293B;
  490. display: block;
  491. }
  492. .invite-time {
  493. font-size: 22rpx;
  494. color: #94A3B8;
  495. margin-top: 4rpx;
  496. }
  497. .empty-state, .loading-state {
  498. text-align: center;
  499. padding: 60rpx;
  500. color: #94A3B8;
  501. font-size: 28rpx;
  502. }
  503. </style>