index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import { Image } from 'expo-image';
  2. import { useRouter } from 'expo-router';
  3. import React, { useCallback, useEffect, useState } from 'react';
  4. import {
  5. Alert,
  6. ImageBackground,
  7. ScrollView,
  8. StatusBar,
  9. StyleSheet,
  10. Text,
  11. TouchableOpacity,
  12. View,
  13. } from 'react-native';
  14. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  15. import { Images } from '@/constants/images';
  16. import services from '@/services/api';
  17. interface DayInfo {
  18. text: string;
  19. date: string;
  20. isFutureDate: boolean;
  21. istoday: boolean;
  22. isSignIn: boolean;
  23. }
  24. interface RecordItem {
  25. id: string;
  26. credit: number;
  27. createTime: string;
  28. }
  29. export default function IntegralScreen() {
  30. const router = useRouter();
  31. const insets = useSafeAreaInsets();
  32. const [integral, setIntegral] = useState(0);
  33. const [daysIntegral, setDaysIntegral] = useState(0);
  34. const [istodaySignIn, setIstodaySignIn] = useState(false);
  35. const [record, setRecord] = useState<RecordItem[]>([]);
  36. const [dataInfo, setDataInfo] = useState<DayInfo[]>([]);
  37. const [todayIntegral, setTodayIntegral] = useState(1);
  38. const [tomorrowIntegral, setTomorrowIntegral] = useState(1);
  39. const [isTooltip, setIsTooltip] = useState(false);
  40. // 格式化数字为两位
  41. const padWithZeros = (number: number, length: number) => {
  42. return String(number).padStart(length, '0');
  43. };
  44. // 设置日期数据
  45. const setDate = useCallback(() => {
  46. const now = new Date();
  47. const dataInfoArr: DayInfo[] = [];
  48. // 前4天
  49. for (let i = -4; i <= 0; i++) {
  50. const date = new Date(now);
  51. date.setDate(date.getDate() + i);
  52. const m = date.getMonth() + 1;
  53. const d = date.getDate();
  54. const y = date.getFullYear();
  55. dataInfoArr.push({
  56. text: `${m}.${d}`,
  57. date: `${y}-${padWithZeros(m, 2)}-${padWithZeros(d, 2)}`,
  58. isFutureDate: false,
  59. istoday: i === 0,
  60. isSignIn: false,
  61. });
  62. }
  63. // 明天
  64. const tomorrow = new Date(now);
  65. tomorrow.setDate(tomorrow.getDate() + 1);
  66. const tm = tomorrow.getMonth() + 1;
  67. const td = tomorrow.getDate();
  68. const ty = tomorrow.getFullYear();
  69. dataInfoArr.push({
  70. text: `${tm}.${td}`,
  71. date: `${ty}-${padWithZeros(tm, 2)}-${padWithZeros(td, 2)}`,
  72. isFutureDate: true,
  73. istoday: false,
  74. isSignIn: false,
  75. });
  76. setDataInfo(dataInfoArr);
  77. return dataInfoArr;
  78. }, []);
  79. // 获取积分信息
  80. const getInfo = useCallback(async () => {
  81. try {
  82. // Use wallet service info instead of user service getWalletInfo
  83. const info = await services.wallet.info('USER_CREDIT');
  84. setIntegral(info?.balance || 0);
  85. } catch (error) {
  86. console.error('获取积分失败:', error);
  87. }
  88. }, []);
  89. // 获取积分规则
  90. const showRule = async () => {
  91. try {
  92. const res = await services.user.getParamConfig('credit_sign');
  93. if (res && res.data) {
  94. Alert.alert('积分规则', res.data.replace(/<[^>]+>/g, '')); // Strip HTML tags for Alert
  95. }
  96. } catch (error) {
  97. console.error('获取规则失败:', error);
  98. }
  99. };
  100. // 获取签到记录
  101. const getData = useCallback(async (dateInfo: DayInfo[]) => {
  102. try {
  103. const fourDaysAgo = new Date();
  104. fourDaysAgo.setDate(fourDaysAgo.getDate() - 4);
  105. const m = fourDaysAgo.getMonth() + 1;
  106. const d = fourDaysAgo.getDate();
  107. const y = fourDaysAgo.getFullYear();
  108. const param = {
  109. createTime: `${y}-${padWithZeros(m, 2)}-${padWithZeros(d, 2)}`,
  110. };
  111. const res = await services.user.getCreditRecord(param);
  112. const recordData = res?.data || [];
  113. setRecord(recordData);
  114. // 今天日期
  115. const now = new Date();
  116. const todayStr = `${now.getFullYear()}-${padWithZeros(now.getMonth() + 1, 2)}-${padWithZeros(now.getDate(), 2)}`;
  117. // 更新签到状态
  118. const updatedDataInfo = dateInfo.map(item => {
  119. const newItem = { ...item };
  120. // 检查是否已签到
  121. for (const rec of recordData) {
  122. const createTime = rec.createTime?.slice(0, 10);
  123. if (createTime === item.date) {
  124. newItem.isSignIn = true;
  125. if (item.date === todayStr) {
  126. setIstodaySignIn(true);
  127. }
  128. }
  129. }
  130. return newItem;
  131. });
  132. setDataInfo(updatedDataInfo);
  133. // 计算签到积分
  134. let total = 0;
  135. for (const rec of recordData) {
  136. if (rec.credit > 0) {
  137. total += rec.credit;
  138. }
  139. }
  140. setDaysIntegral(total);
  141. // 计算今天和明天的积分
  142. if (recordData.length > 0) {
  143. const lastCredit = recordData[0]?.credit || 1;
  144. const yesterday = new Date();
  145. yesterday.setDate(yesterday.getDate() - 1);
  146. const yesterdayStr = `${yesterday.getFullYear()}-${padWithZeros(yesterday.getMonth() + 1, 2)}-${padWithZeros(yesterday.getDate(), 2)}`;
  147. const lastRecordDate = recordData[0]?.createTime?.slice(0, 10);
  148. if (yesterdayStr === lastRecordDate) {
  149. setTodayIntegral(lastCredit + 1);
  150. setTomorrowIntegral(lastCredit + 2);
  151. } else {
  152. setTodayIntegral(lastCredit);
  153. setTomorrowIntegral(Math.min(lastCredit + 1, 7));
  154. }
  155. }
  156. } catch (error) {
  157. console.error('获取签到记录失败:', error);
  158. }
  159. }, []);
  160. // 初始化
  161. useEffect(() => {
  162. const dateInfo = setDate();
  163. getInfo();
  164. getData(dateInfo);
  165. }, [setDate, getInfo, getData]);
  166. // 签到
  167. const handleSignIn = async () => {
  168. if (istodaySignIn) {
  169. Alert.alert('提示', '今天已签到');
  170. return;
  171. }
  172. try {
  173. const res = await services.user.signIn();
  174. if (res?.code === 0) {
  175. Alert.alert('提示', '签到成功');
  176. setIstodaySignIn(true);
  177. // Optimistic update for UI
  178. const optimisticDateInfo = dataInfo.map(item => {
  179. if (item.istoday) {
  180. return { ...item, isSignIn: true };
  181. }
  182. return item;
  183. });
  184. setDataInfo(optimisticDateInfo);
  185. // Delay fetch to ensure backend data consistency
  186. setTimeout(() => {
  187. const dateInfo = setDate();
  188. getInfo();
  189. getData(dateInfo);
  190. }, 500);
  191. } else {
  192. Alert.alert('提示', res?.msg || '签到失败');
  193. }
  194. } catch (error) {
  195. console.error('签到失败:', error);
  196. Alert.alert('提示', '签到失败');
  197. }
  198. };
  199. const handleBack = () => {
  200. router.back();
  201. };
  202. return (
  203. <View style={styles.container}>
  204. <StatusBar barStyle="dark-content" />
  205. {/* 顶部导航 */}
  206. <View style={[styles.header, { paddingTop: insets.top }]}>
  207. <TouchableOpacity style={styles.backBtn} onPress={handleBack}>
  208. <Text style={styles.backIcon}>‹</Text>
  209. </TouchableOpacity>
  210. <Text style={styles.title}>我的积分</Text>
  211. <View style={styles.placeholder} />
  212. </View>
  213. <ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
  214. {/* 头部积分展示 */}
  215. <ImageBackground
  216. source={{ uri: Images.integral?.head || Images.common.commonBg }}
  217. style={styles.headerBg}
  218. resizeMode="cover"
  219. >
  220. <Text style={styles.integralNum}>{integral}</Text>
  221. <TouchableOpacity
  222. style={styles.allBox}
  223. onPress={() => setIsTooltip(!isTooltip)}
  224. >
  225. <Text style={styles.allText}>所有积分</Text>
  226. <Image
  227. source={{ uri: Images.integral?.greetings }}
  228. style={styles.infoIcon}
  229. contentFit="contain"
  230. />
  231. {isTooltip && (
  232. <ImageBackground
  233. source={{uri: Images.integral?.tooltip }}
  234. style={styles.tooltip}
  235. resizeMode="stretch"
  236. >
  237. <Text style={styles.tooltipText}>消费积分与签到积分总和</Text>
  238. </ImageBackground>
  239. )}
  240. </TouchableOpacity>
  241. <View style={styles.todayBox}>
  242. <Text style={styles.todayText}>
  243. 今日已获得<Text style={styles.todayNum}>{istodaySignIn ? todayIntegral : 0}</Text>积分
  244. </Text>
  245. </View>
  246. <TouchableOpacity style={styles.ruleBtn} onPress={showRule}>
  247. <Text style={styles.ruleText}>积分规则</Text>
  248. </TouchableOpacity>
  249. </ImageBackground>
  250. <View style={styles.content}>
  251. {/* 签到面板 */}
  252. <View style={styles.panel}>
  253. <View style={styles.panelTitle}>
  254. <View style={styles.panelTitleLeft}>
  255. <Image
  256. source={{ uri: Images.integral?.goldCoins }}
  257. style={styles.goldIcon}
  258. contentFit="contain"
  259. />
  260. <Text style={styles.panelTitleText}>签到领积分</Text>
  261. </View>
  262. <Text style={[styles.panelTitleRight, istodaySignIn && styles.signedText]}>
  263. {istodaySignIn ? '今日已签到' : '今日未签到'}
  264. </Text>
  265. </View>
  266. <Text style={styles.explain}>
  267. 已签到{record.filter(r => r.credit > 0).length}天,共获得<Text style={styles.highlightText}>{daysIntegral}</Text>积分
  268. </Text>
  269. {/* 签到进度 */}
  270. <View style={styles.progressBox}>
  271. <View style={styles.lineBox}>
  272. {[1, 2, 3, 4, 5].map((_, index) => (
  273. <View key={index} style={styles.lineSection}>
  274. <View style={[styles.line, styles.lineOn]} />
  275. </View>
  276. ))}
  277. </View>
  278. <View style={styles.daysBox}>
  279. {dataInfo.map((item, index) => (
  280. <View key={index} style={styles.dayItem}>
  281. <TouchableOpacity
  282. style={styles.dayCircleBox}
  283. onPress={item.istoday && !istodaySignIn ? handleSignIn : undefined}
  284. activeOpacity={item.istoday && !istodaySignIn ? 0.7 : 1}
  285. >
  286. {item.istoday && !istodaySignIn ? (
  287. <ImageBackground
  288. source={{ uri: Images.integral?.basisBg }}
  289. style={styles.dayCircleBg}
  290. resizeMode="contain"
  291. >
  292. <View style={[styles.dayCircle, styles.todayCircle]}>
  293. <Text style={styles.todayCircleText}>+{todayIntegral}</Text>
  294. </View>
  295. </ImageBackground>
  296. ) : item.isFutureDate ? (
  297. <View style={[styles.dayCircle, styles.futureCircle]}>
  298. <Text style={styles.futureText}>+{tomorrowIntegral}</Text>
  299. </View>
  300. ) : item.isSignIn ? (
  301. <View style={[styles.dayCircle, styles.signedCircle]}>
  302. <Text style={styles.checkIcon}>✓</Text>
  303. </View>
  304. ) : (
  305. <View style={[styles.dayCircle, styles.missedCircle]}>
  306. <Text style={styles.missedIcon}>✗</Text>
  307. </View>
  308. )}
  309. </TouchableOpacity>
  310. <Text style={[styles.dayText, item.istoday && styles.todayDayText]}>
  311. {item.istoday ? '今天' : item.text}
  312. </Text>
  313. </View>
  314. ))}
  315. </View>
  316. </View>
  317. </View>
  318. {/* 积分明细 */}
  319. <View style={styles.listSection}>
  320. <Text style={styles.listTitle}>积分明细</Text>
  321. {record.map((item, index) => (
  322. <View key={item.id || index} style={styles.listItem}>
  323. <View style={styles.listItemLeft}>
  324. <Text style={styles.listItemTitle}>
  325. {item.credit > 0 ? '积分签到获得' : '大转盘消费'}
  326. </Text>
  327. <Text style={styles.listItemTime}>{item.createTime}</Text>
  328. </View>
  329. <Text style={[
  330. styles.listItemCredit,
  331. item.credit > 0 ? styles.creditPositive : styles.creditNegative
  332. ]}>
  333. {item.credit > 0 ? '+' : ''}{item.credit}
  334. </Text>
  335. </View>
  336. ))}
  337. {record.length === 0 && (
  338. <View style={styles.emptyBox}>
  339. <Text style={styles.emptyText}>暂无积分记录</Text>
  340. </View>
  341. )}
  342. </View>
  343. </View>
  344. </ScrollView>
  345. </View>
  346. );
  347. }
  348. const styles = StyleSheet.create({
  349. container: {
  350. flex: 1,
  351. backgroundColor: '#F8FAFB',
  352. },
  353. header: {
  354. flexDirection: 'row',
  355. alignItems: 'center',
  356. justifyContent: 'space-between',
  357. paddingHorizontal: 10,
  358. height: 80,
  359. backgroundColor: 'transparent',
  360. position: 'absolute',
  361. top: 0,
  362. left: 0,
  363. right: 0,
  364. zIndex: 100,
  365. },
  366. backBtn: {
  367. width: 40,
  368. height: 40,
  369. justifyContent: 'center',
  370. alignItems: 'center',
  371. },
  372. backIcon: {
  373. fontSize: 32,
  374. color: '#000',
  375. fontWeight: 'bold',
  376. },
  377. title: {
  378. fontSize: 15,
  379. fontWeight: 'bold',
  380. color: '#000',
  381. },
  382. placeholder: {
  383. width: 40,
  384. },
  385. scrollView: {
  386. flex: 1,
  387. },
  388. headerBg: {
  389. width: '100%',
  390. height: 283,
  391. paddingTop: 100,
  392. alignItems: 'center',
  393. position: 'relative',
  394. },
  395. integralNum: {
  396. fontSize: 48,
  397. fontWeight: '400',
  398. color: '#5B460F',
  399. textAlign: 'center',
  400. },
  401. allBox: {
  402. flexDirection: 'row',
  403. alignItems: 'center',
  404. marginTop: 7,
  405. marginBottom: 12,
  406. position: 'relative',
  407. },
  408. allText: {
  409. fontSize: 12,
  410. color: '#C8B177',
  411. },
  412. infoIcon: {
  413. width: 16,
  414. height: 16,
  415. marginLeft: 4,
  416. },
  417. tooltip: {
  418. position: 'absolute',
  419. width: 150,
  420. height: 27,
  421. top: 15,
  422. left: 30, // Adjust execution based on design
  423. justifyContent: 'center',
  424. alignItems: 'center',
  425. zIndex: 999,
  426. paddingHorizontal: 5,
  427. },
  428. tooltipText: {
  429. color: '#fff',
  430. fontSize: 10,
  431. },
  432. todayBox: {
  433. backgroundColor: 'rgba(0,0,0,0.08)',
  434. borderRadius: 217,
  435. paddingHorizontal: 15,
  436. paddingVertical: 5,
  437. },
  438. todayText: {
  439. fontSize: 12,
  440. color: '#8A794F',
  441. },
  442. todayNum: {
  443. fontWeight: '800',
  444. color: '#5B460F',
  445. },
  446. ruleBtn: {
  447. position: 'absolute',
  448. right: 0,
  449. top: 130, // Half of 260rpx approx
  450. backgroundColor: '#fff',
  451. borderTopLeftRadius: 20,
  452. borderBottomLeftRadius: 20,
  453. paddingHorizontal: 10,
  454. paddingVertical: 6,
  455. zIndex: 10,
  456. },
  457. ruleText: {
  458. fontSize: 12,
  459. color: '#333',
  460. },
  461. content: {
  462. paddingHorizontal: 10,
  463. marginTop: -50,
  464. },
  465. panel: {
  466. backgroundColor: '#fff',
  467. borderRadius: 15,
  468. padding: 12,
  469. },
  470. panelTitle: {
  471. flexDirection: 'row',
  472. justifyContent: 'space-between',
  473. alignItems: 'center',
  474. paddingHorizontal: 10,
  475. },
  476. panelTitleLeft: {
  477. flexDirection: 'row',
  478. alignItems: 'center',
  479. },
  480. goldIcon: {
  481. width: 24,
  482. height: 24,
  483. marginRight: 6,
  484. },
  485. panelTitleText: {
  486. fontSize: 16,
  487. fontWeight: '700',
  488. color: '#3D3D3D',
  489. },
  490. panelTitleRight: {
  491. fontSize: 12,
  492. color: '#FC7D2E',
  493. },
  494. signedText: {
  495. color: '#999',
  496. },
  497. explain: {
  498. fontSize: 12,
  499. color: '#999',
  500. paddingHorizontal: 10,
  501. marginTop: 7,
  502. marginBottom: 11,
  503. },
  504. highlightText: {
  505. color: '#FC7D2E',
  506. },
  507. progressBox: {
  508. paddingTop: 20,
  509. },
  510. lineBox: {
  511. flexDirection: 'row',
  512. paddingHorizontal: 10,
  513. },
  514. lineSection: {
  515. flex: 1,
  516. },
  517. line: {
  518. height: 1,
  519. backgroundColor: '#9E9E9E',
  520. },
  521. lineOn: {
  522. backgroundColor: '#FC7D2E',
  523. },
  524. daysBox: {
  525. flexDirection: 'row',
  526. marginTop: -25,
  527. },
  528. dayItem: {
  529. flex: 1,
  530. alignItems: 'center',
  531. },
  532. dayCircleBox: {
  533. width: 46,
  534. height: 46,
  535. justifyContent: 'center',
  536. alignItems: 'center',
  537. marginBottom: 3,
  538. },
  539. dayCircleBg: {
  540. width: 46,
  541. height: 46,
  542. justifyContent: 'center',
  543. alignItems: 'center',
  544. },
  545. dayCircle: {
  546. width: 32,
  547. height: 32,
  548. borderRadius: 16,
  549. justifyContent: 'center',
  550. alignItems: 'center',
  551. },
  552. todayCircle: {
  553. backgroundColor: '#FC7D2E',
  554. },
  555. todayCircleText: {
  556. color: '#fff',
  557. fontSize: 12,
  558. fontWeight: 'bold',
  559. },
  560. futureCircle: {
  561. backgroundColor: '#F4F6F8',
  562. borderWidth: 1,
  563. borderColor: 'rgba(226,226,226,0.5)',
  564. },
  565. futureText: {
  566. color: '#505050',
  567. fontSize: 12,
  568. },
  569. signedCircle: {
  570. backgroundColor: '#FFE7C4',
  571. },
  572. checkIcon: {
  573. color: '#FC7D2E',
  574. fontSize: 16,
  575. fontWeight: 'bold',
  576. },
  577. missedCircle: {
  578. backgroundColor: '#F4F6F8',
  579. },
  580. missedIcon: {
  581. color: '#999',
  582. fontSize: 16,
  583. },
  584. dayText: {
  585. fontSize: 12,
  586. color: '#9E9E9E',
  587. },
  588. todayDayText: {
  589. color: '#FC7D2E',
  590. },
  591. listSection: {
  592. backgroundColor: '#fff',
  593. borderRadius: 15,
  594. padding: 12,
  595. marginTop: 10,
  596. marginBottom: 30,
  597. },
  598. listTitle: {
  599. fontSize: 16,
  600. fontWeight: '700',
  601. color: '#3D3D3D',
  602. marginBottom: 10,
  603. },
  604. listItem: {
  605. flexDirection: 'row',
  606. justifyContent: 'space-between',
  607. alignItems: 'center',
  608. paddingVertical: 14,
  609. borderBottomWidth: 1,
  610. borderBottomColor: 'rgba(0,0,0,0.05)',
  611. },
  612. listItemLeft: {},
  613. listItemTitle: {
  614. fontSize: 14,
  615. color: '#333',
  616. marginBottom: 3,
  617. },
  618. listItemTime: {
  619. fontSize: 12,
  620. color: '#999',
  621. },
  622. listItemCredit: {
  623. fontSize: 18,
  624. fontWeight: '700',
  625. },
  626. creditPositive: {
  627. color: '#588CFF',
  628. },
  629. creditNegative: {
  630. color: '#FC7D2E',
  631. },
  632. emptyBox: {
  633. paddingVertical: 50,
  634. alignItems: 'center',
  635. },
  636. emptyText: {
  637. fontSize: 14,
  638. color: '#999',
  639. },
  640. });