box.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import { Barrage } from "@/components/Barrage";
  2. import { CouponModal } from "@/components/CouponModal";
  3. import { NewbieActivityModal } from "@/components/NewbieActivityModal";
  4. import { Colors } from "@/constants/Colors";
  5. import { Images } from "@/constants/images";
  6. import { CouponItem, getNewbieGiftBagInfo, listValidCoupon } from "@/services/activity";
  7. import { getFeedbackList, getPoolList, PoolItem } from "@/services/award";
  8. import { getToken } from "@/services/http";
  9. import AsyncStorage from "@react-native-async-storage/async-storage";
  10. import { Image } from "expo-image";
  11. import { useFocusEffect, useRouter } from "expo-router";
  12. import React, { useCallback, useEffect, useMemo, useState } from "react";
  13. import {
  14. ActivityIndicator,
  15. Dimensions,
  16. FlatList,
  17. RefreshControl,
  18. StatusBar,
  19. StyleSheet,
  20. Text,
  21. TextInput,
  22. TouchableOpacity,
  23. View,
  24. } from "react-native";
  25. import { useSafeAreaInsets } from "react-native-safe-area-context";
  26. const { width: SCREEN_WIDTH } = Dimensions.get("window");
  27. const typeList = [
  28. { label: "全部", value: "", type: 0 },
  29. { label: "高保赏", value: "UNLIMITED", type: 2 },
  30. { label: "高爆赏", value: "UNLIMITED", type: 1 },
  31. { label: "擂台赏", value: "YFS_PRO", type: 7 },
  32. { label: "一番赏", value: "YFS_PRO", type: 0 },
  33. ];
  34. interface BarrageItem {
  35. id: string;
  36. content: string;
  37. nickname?: string;
  38. avatar: string;
  39. poolName?: string;
  40. type?: string;
  41. text?: string;
  42. poolId?: string;
  43. }
  44. // Static Header Component
  45. const StaticHeader = React.memo(
  46. ({ barrageList }: { barrageList: BarrageItem[] }) => (
  47. <View>
  48. <View style={{ height: 60 }} />
  49. {/* Featured Banner Area */}
  50. <View style={styles.bannerContainer}>
  51. {/* Replace image with a styled cyber container for now */}
  52. <View style={styles.cyberBanner}>
  53. <Text style={styles.bannerTitle}>MATRIX</Text>
  54. <Text style={styles.bannerSubtitle}>SUPPLY CRATE</Text>
  55. {/* Visual tech lines */}
  56. <View style={styles.techLine1} />
  57. <View style={styles.techLine2} />
  58. </View>
  59. </View>
  60. {/* Barrage Area */}
  61. {barrageList && barrageList.length > 0 && (
  62. <View style={styles.barrageSection}>
  63. <Barrage
  64. data={barrageList.slice(0, Math.ceil(barrageList.length / 2))}
  65. />
  66. <View style={{ height: 8 }} />
  67. <Barrage
  68. data={barrageList.slice(Math.ceil(barrageList.length / 2))}
  69. speed={35}
  70. />
  71. </View>
  72. )}
  73. </View>
  74. ),
  75. );
  76. // Type Selector Component
  77. const TypeSelector = React.memo(
  78. ({
  79. typeIndex,
  80. priceSort,
  81. onTypeChange,
  82. onSortChange,
  83. }: {
  84. typeIndex: number;
  85. priceSort: number;
  86. onTypeChange: (index: number) => void;
  87. onSortChange: () => void;
  88. }) => (
  89. <View style={styles.typeSection}>
  90. <View style={styles.typeListContainer}>
  91. <FlatList
  92. horizontal
  93. showsHorizontalScrollIndicator={false}
  94. data={typeList}
  95. keyExtractor={(_, index) => index.toString()}
  96. renderItem={({ item, index }) => {
  97. const isActive = typeIndex === index;
  98. return (
  99. <TouchableOpacity
  100. style={[styles.typeItem, isActive && styles.typeItemActive]}
  101. onPress={() => onTypeChange(index)}
  102. activeOpacity={0.7}
  103. >
  104. <Text
  105. style={[styles.typeText, isActive && styles.typeTextActive]}
  106. >
  107. {item.label}
  108. </Text>
  109. </TouchableOpacity>
  110. );
  111. }}
  112. contentContainerStyle={styles.typeListContent}
  113. />
  114. </View>
  115. <TouchableOpacity
  116. style={styles.sortBtn}
  117. onPress={onSortChange}
  118. activeOpacity={0.7}
  119. >
  120. <Text style={styles.sortBtnText}>
  121. 价格 {priceSort === 0 ? "-" : priceSort === 1 ? "↑" : "↓"}
  122. </Text>
  123. </TouchableOpacity>
  124. </View>
  125. ),
  126. );
  127. export default function BoxScreen() {
  128. const router = useRouter();
  129. const insets = useSafeAreaInsets();
  130. const [keyword, setKeyword] = useState("");
  131. const [typeIndex, setTypeIndex] = useState(0);
  132. const [priceSort, setPriceSort] = useState(0);
  133. const [list, setList] = useState<PoolItem[]>([]);
  134. const [loading, setLoading] = useState(false);
  135. const [refreshing, setRefreshing] = useState(false);
  136. const [current, setCurrent] = useState(1);
  137. const [hasMore, setHasMore] = useState(true);
  138. const [barrageList, setBarrageList] = useState<BarrageItem[]>([]);
  139. // 新人优惠券弹窗
  140. const [couponVisible, setCouponVisible] = useState(false);
  141. const [couponList, setCouponList] = useState<CouponItem[]>([]);
  142. const [newPeopleChecked, setNewPeopleChecked] = useState(false);
  143. // 新人大礼包海报弹窗
  144. const [activityVisible, setActivityVisible] = useState(false);
  145. // 加载弹幕
  146. const loadBarrage = useCallback(async () => {
  147. try {
  148. const res = await getFeedbackList();
  149. if (res.data) {
  150. setBarrageList(res.data);
  151. }
  152. } catch (error) {
  153. console.error("加载弹幕失败:", error);
  154. }
  155. }, []);
  156. // 新人大礼包检测(Vue: initNewPeople)—— 一个设备只弹一次
  157. const initNewPeople = useCallback(async () => {
  158. if (newPeopleChecked) return;
  159. const token = getToken();
  160. if (!token) return; // 未登录不检测
  161. try {
  162. const flag = await AsyncStorage.getItem("NEW_PEOPLE");
  163. if (flag) {
  164. setNewPeopleChecked(true);
  165. return;
  166. }
  167. const data = await getNewbieGiftBagInfo();
  168. if (data) {
  169. setNewPeopleChecked(true);
  170. setActivityVisible(true);
  171. await AsyncStorage.setItem("NEW_PEOPLE", "1");
  172. }
  173. } catch (e) {
  174. // 静默失败
  175. }
  176. }, [newPeopleChecked]);
  177. // 优惠券检测(Vue: initCoupon)
  178. const initCoupon = useCallback(async () => {
  179. const token = getToken();
  180. if (!token) return;
  181. try {
  182. const coupons = await listValidCoupon("LUCK");
  183. if (coupons && coupons.length > 0) {
  184. setCouponList(coupons);
  185. setCouponVisible(true);
  186. }
  187. } catch (e) {
  188. // 静默失败
  189. }
  190. }, []);
  191. const loadData = useCallback(
  192. async (isRefresh = false) => {
  193. if (loading) return;
  194. const page = isRefresh ? 1 : current;
  195. if (!isRefresh && !hasMore) return;
  196. setLoading(true);
  197. try {
  198. const selectedType = typeList[typeIndex];
  199. const res = await getPoolList({
  200. current: page,
  201. size: 10,
  202. mode: selectedType.value || undefined,
  203. type: selectedType.type,
  204. keyword: keyword || undefined,
  205. priceSort: priceSort || undefined,
  206. });
  207. if (res.success && res.data) {
  208. const newList = isRefresh ? res.data : [...list, ...res.data];
  209. setList(newList);
  210. setCurrent(page + 1);
  211. setHasMore(newList.length < (res.count || 0));
  212. }
  213. } catch (error) {
  214. console.error("加载奖池列表失败:", error);
  215. }
  216. setLoading(false);
  217. setRefreshing(false);
  218. },
  219. [current, hasMore, loading, typeIndex, list, keyword, priceSort],
  220. );
  221. useEffect(() => {
  222. loadData(true);
  223. loadBarrage();
  224. }, [typeIndex, priceSort]);
  225. // 对齐 Vue onShow:每次页面获得焦点检查新人弹窗/优惠券
  226. useFocusEffect(
  227. useCallback(() => {
  228. initNewPeople();
  229. initCoupon();
  230. }, [initNewPeople, initCoupon]),
  231. );
  232. // 执行搜索
  233. const handleSearch = () => {
  234. setList([]);
  235. setCurrent(1);
  236. setHasMore(true);
  237. setTimeout(() => {
  238. loadData(true);
  239. }, 100);
  240. };
  241. const handleRefresh = () => {
  242. setRefreshing(true);
  243. loadData(true);
  244. };
  245. const handleLoadMore = () => {
  246. if (!loading && hasMore) {
  247. loadData(false);
  248. }
  249. };
  250. const handleTypeChange = useCallback((index: number) => {
  251. setTypeIndex(index);
  252. setList([]);
  253. setCurrent(1);
  254. setHasMore(true);
  255. }, []);
  256. const handlePriceSort = useCallback(() => {
  257. setPriceSort((prev) => (prev + 1) % 3);
  258. }, []);
  259. const handleItemPress = useCallback(
  260. (item: PoolItem) => {
  261. if (item.status !== undefined && item.status !== 1) return;
  262. if (item.type === 7) {
  263. router.push({
  264. pathname: "/boxInBox",
  265. params: { poolId: item.id },
  266. } as any);
  267. } else if (item.mode === "UNLIMITED") {
  268. router.push({
  269. pathname: "/award-detail",
  270. params: { poolId: item.id },
  271. } as any);
  272. } else if (item.mode === "YFS_PRO") {
  273. router.push({
  274. pathname: "/award-detail-yfs",
  275. params: { poolId: item.id },
  276. } as any);
  277. } else {
  278. router.push(`/product/${item.id}` as any);
  279. }
  280. },
  281. [router],
  282. );
  283. const renderItem = useCallback(
  284. ({ item }: { item: PoolItem }) => (
  285. <TouchableOpacity
  286. style={styles.itemContainer}
  287. onPress={() => handleItemPress(item)}
  288. activeOpacity={0.8}
  289. >
  290. <View style={styles.itemCard}>
  291. <View style={styles.itemImageContainer}>
  292. <Image
  293. source={{ uri: item.cover }}
  294. style={styles.itemImage}
  295. contentFit="cover"
  296. />
  297. {/* Cyber Border Overlay */}
  298. <View style={styles.itemCyberOverlay} />
  299. </View>
  300. <View style={styles.itemInfo}>
  301. <Text style={styles.itemName} numberOfLines={1}>
  302. {item.name}
  303. </Text>
  304. <Text style={styles.itemPrice}>
  305. <Text style={styles.priceUnit}>¥</Text>
  306. {item.price} <Text style={styles.priceStart}>起</Text>
  307. </Text>
  308. </View>
  309. </View>
  310. </TouchableOpacity>
  311. ),
  312. [handleItemPress],
  313. );
  314. const ListHeader = useMemo(
  315. () => (
  316. <View>
  317. <StaticHeader barrageList={barrageList} />
  318. <TypeSelector
  319. typeIndex={typeIndex}
  320. priceSort={priceSort}
  321. onTypeChange={handleTypeChange}
  322. onSortChange={handlePriceSort}
  323. />
  324. </View>
  325. ),
  326. [barrageList, typeIndex, priceSort, handleTypeChange, handlePriceSort],
  327. );
  328. const renderFooter = useCallback(() => {
  329. if (!loading) return null;
  330. return (
  331. <View style={styles.footer}>
  332. <ActivityIndicator size="small" color={Colors.neonBlue} />
  333. <Text style={styles.footerText}>数据传输中...</Text>
  334. </View>
  335. );
  336. }, [loading]);
  337. const renderEmpty = useCallback(() => {
  338. if (loading) return null;
  339. return (
  340. <View style={styles.empty}>
  341. <Text style={styles.emptyText}>暂无数据</Text>
  342. </View>
  343. );
  344. }, [loading]);
  345. return (
  346. <View style={styles.container}>
  347. <StatusBar barStyle="light-content" />
  348. <View style={styles.background}>
  349. {/* Header */}
  350. <View style={[styles.header, { paddingTop: insets.top + 10 }]}>
  351. <View style={styles.logoArea}>
  352. <Text style={styles.logoText}>
  353. CYBER<Text style={styles.logoHighlight}>BOX</Text>
  354. </Text>
  355. </View>
  356. <View style={styles.searchBar}>
  357. <Image
  358. source={{ uri: Images.home.search }}
  359. style={styles.searchIcon}
  360. contentFit="contain"
  361. tintColor={Colors.neonBlue}
  362. />
  363. <TextInput
  364. style={styles.searchInput}
  365. value={keyword}
  366. onChangeText={setKeyword}
  367. placeholder="搜索奖池"
  368. placeholderTextColor={Colors.textTertiary}
  369. returnKeyType="search"
  370. onSubmitEditing={handleSearch}
  371. />
  372. </View>
  373. </View>
  374. {/* List */}
  375. <FlatList
  376. data={list}
  377. renderItem={renderItem}
  378. keyExtractor={(item) => item.id}
  379. ListHeaderComponent={ListHeader}
  380. ListFooterComponent={renderFooter}
  381. ListEmptyComponent={renderEmpty}
  382. contentContainerStyle={styles.listContent}
  383. showsVerticalScrollIndicator={false}
  384. refreshControl={
  385. <RefreshControl
  386. refreshing={refreshing}
  387. onRefresh={handleRefresh}
  388. tintColor={Colors.neonBlue}
  389. />
  390. }
  391. onEndReached={handleLoadMore}
  392. onEndReachedThreshold={0.3}
  393. />
  394. {/* 新人优惠券弹窗 */}
  395. <CouponModal
  396. visible={couponVisible}
  397. coupons={couponList}
  398. onClose={() => setCouponVisible(false)}
  399. onSuccess={() => {
  400. setCouponList([]);
  401. loadData(true);
  402. }}
  403. />
  404. {/* 新人大礼包海报弹窗 */}
  405. <NewbieActivityModal
  406. visible={activityVisible}
  407. onClose={() => setActivityVisible(false)}
  408. />
  409. </View>
  410. </View>
  411. );
  412. }
  413. const styles = StyleSheet.create({
  414. container: {
  415. flex: 1,
  416. backgroundColor: Colors.darkBg,
  417. },
  418. background: {
  419. flex: 1,
  420. backgroundColor: Colors.darkBg,
  421. },
  422. header: {
  423. position: "relative",
  424. zIndex: 11,
  425. flexDirection: "row",
  426. alignItems: "center",
  427. paddingHorizontal: 15,
  428. paddingBottom: 10,
  429. backgroundColor: Colors.darkBg,
  430. },
  431. logoArea: {
  432. marginRight: 15,
  433. },
  434. logoText: {
  435. color: "#fff",
  436. fontWeight: "bold",
  437. fontSize: 18,
  438. fontStyle: "italic",
  439. },
  440. logoHighlight: {
  441. color: Colors.neonPink,
  442. },
  443. searchBar: {
  444. flex: 1,
  445. flexDirection: "row",
  446. alignItems: "center",
  447. backgroundColor: "rgba(255,255,255,0.05)",
  448. borderRadius: 20,
  449. paddingHorizontal: 15,
  450. height: 32,
  451. borderWidth: 1,
  452. borderColor: "rgba(0, 243, 255, 0.3)",
  453. },
  454. searchIcon: {
  455. width: 14,
  456. height: 14,
  457. marginRight: 8,
  458. },
  459. searchInput: {
  460. flex: 1,
  461. color: "#fff",
  462. fontSize: 12,
  463. padding: 0,
  464. },
  465. // Banner
  466. bannerContainer: {
  467. height: 180,
  468. marginHorizontal: 10,
  469. marginTop: 10,
  470. marginBottom: 20,
  471. borderRadius: 12,
  472. overflow: "hidden",
  473. },
  474. cyberBanner: {
  475. flex: 1,
  476. backgroundColor: Colors.darkCard,
  477. justifyContent: "center",
  478. alignItems: "center",
  479. borderWidth: 1,
  480. borderColor: Colors.neonPink,
  481. position: "relative",
  482. },
  483. bannerTitle: {
  484. fontSize: 32,
  485. fontWeight: "bold",
  486. color: "#fff",
  487. textShadowColor: Colors.neonPink,
  488. textShadowRadius: 10,
  489. fontStyle: "italic",
  490. },
  491. bannerSubtitle: {
  492. fontSize: 14,
  493. color: Colors.neonBlue,
  494. letterSpacing: 4,
  495. marginTop: 5,
  496. },
  497. techLine1: {
  498. position: "absolute",
  499. top: 10,
  500. left: 10,
  501. width: 20,
  502. height: 2,
  503. backgroundColor: Colors.neonBlue,
  504. },
  505. techLine2: {
  506. position: "absolute",
  507. bottom: 10,
  508. right: 10,
  509. width: 20,
  510. height: 2,
  511. backgroundColor: Colors.neonBlue,
  512. },
  513. // Type Selector
  514. typeSection: {
  515. flexDirection: "row",
  516. alignItems: "center",
  517. paddingHorizontal: 10,
  518. paddingVertical: 10,
  519. zIndex: 10,
  520. },
  521. typeListContainer: {
  522. flex: 1,
  523. marginRight: 5,
  524. },
  525. typeListContent: {
  526. paddingRight: 10,
  527. },
  528. typeItem: {
  529. paddingHorizontal: 12,
  530. paddingVertical: 6,
  531. borderRadius: 15,
  532. backgroundColor: "rgba(255,255,255,0.05)",
  533. marginRight: 8,
  534. borderWidth: 1,
  535. borderColor: "transparent",
  536. },
  537. typeItemActive: {
  538. backgroundColor: "rgba(0, 243, 255, 0.15)",
  539. borderColor: Colors.neonBlue,
  540. },
  541. typeText: {
  542. color: Colors.textSecondary,
  543. fontSize: 12,
  544. fontWeight: "600",
  545. },
  546. typeTextActive: {
  547. color: "#fff",
  548. textShadowColor: Colors.neonBlue,
  549. textShadowRadius: 5,
  550. },
  551. sortBtn: {
  552. paddingHorizontal: 8,
  553. paddingVertical: 6,
  554. alignItems: "center",
  555. justifyContent: "center",
  556. backgroundColor: "rgba(255,255,255,0.05)",
  557. borderRadius: 15,
  558. },
  559. sortBtnText: {
  560. color: Colors.textTertiary,
  561. fontSize: 12,
  562. },
  563. // List
  564. listContent: {
  565. paddingHorizontal: 10,
  566. paddingBottom: 100,
  567. },
  568. itemContainer: {
  569. marginBottom: 12,
  570. },
  571. itemCard: {
  572. width: "100%",
  573. backgroundColor: Colors.darkCard,
  574. borderRadius: 8,
  575. overflow: "hidden",
  576. borderWidth: 1,
  577. borderColor: "rgba(0, 243, 255, 0.2)",
  578. },
  579. itemImageContainer: {
  580. height: 160,
  581. width: "100%",
  582. position: "relative",
  583. },
  584. itemImage: {
  585. width: "100%",
  586. height: "100%",
  587. },
  588. itemCyberOverlay: {
  589. ...StyleSheet.absoluteFillObject,
  590. backgroundColor: "rgba(0,0,0,0.1)", // Slight dark overlay
  591. borderBottomWidth: 1,
  592. borderBottomColor: Colors.neonBlue,
  593. },
  594. itemInfo: {
  595. flexDirection: "row",
  596. justifyContent: "space-between",
  597. alignItems: "center",
  598. padding: 12,
  599. },
  600. itemName: {
  601. flex: 1,
  602. color: Colors.textPrimary,
  603. fontSize: 14,
  604. fontWeight: "bold",
  605. },
  606. itemPrice: {
  607. color: Colors.neonBlue,
  608. fontSize: 16,
  609. fontWeight: "bold",
  610. marginLeft: 10,
  611. textShadowColor: Colors.neonBlue,
  612. textShadowRadius: 5,
  613. },
  614. priceUnit: {
  615. fontSize: 12,
  616. marginRight: 2,
  617. },
  618. priceStart: {
  619. fontSize: 10,
  620. color: Colors.textTertiary,
  621. fontWeight: "normal",
  622. },
  623. // Footer
  624. footer: {
  625. paddingVertical: 15,
  626. alignItems: "center",
  627. flexDirection: "row",
  628. justifyContent: "center",
  629. },
  630. footerText: {
  631. color: Colors.textTertiary,
  632. fontSize: 12,
  633. marginLeft: 8,
  634. },
  635. empty: {
  636. alignItems: "center",
  637. paddingVertical: 50,
  638. },
  639. emptyText: {
  640. color: Colors.textTertiary,
  641. fontSize: 14,
  642. },
  643. // Barrage
  644. barrageSection: {
  645. marginVertical: 10,
  646. paddingHorizontal: 5, // Edge to edge
  647. },
  648. });