box.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. import { Barrage } from "@/components/Barrage";
  2. import { CouponModal } from "@/components/CouponModal";
  3. import { AdElement, SuperAdModal } from "@/components/SuperAdModal";
  4. import { Colors } from "@/constants/Colors";
  5. import { Images } from "@/constants/images";
  6. import { CouponItem, listValidCoupon } from "@/services/activity";
  7. import { getFeedbackList, getPoolList, PoolItem } from "@/services/award";
  8. import { getPageConfig } from "@/services/base";
  9. import { getToken } from "@/services/http";
  10. import { Image } from "expo-image";
  11. import { useFocusEffect, useRouter } from "expo-router";
  12. import React, { useCallback, useEffect, useMemo, useRef, 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. // 广告弹窗 (super_ad)
  143. const [adElements, setAdElements] = useState<AdElement[]>([]);
  144. const adShownRef = useRef(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: App.vue onShow 加载 super_ad + pages/award/index.vue 展示)
  157. // 每个 session (冷启动) 只展示一次
  158. const initSuperAd = useCallback(async () => {
  159. if (adShownRef.current) return;
  160. try {
  161. const config = await getPageConfig("super_ad_app");
  162. const elements = (config?.components?.[0]?.elements || []) as AdElement[];
  163. if (elements.length > 0) {
  164. adShownRef.current = true;
  165. setAdElements(elements);
  166. }
  167. } catch (e) {
  168. // 静默失败
  169. }
  170. }, []);
  171. // 优惠券检测(Vue: initCoupon)
  172. const initCoupon = useCallback(async () => {
  173. const token = getToken();
  174. if (!token) return;
  175. try {
  176. const coupons = await listValidCoupon("LUCK");
  177. if (coupons && coupons.length > 0) {
  178. setCouponList(coupons);
  179. setCouponVisible(true);
  180. }
  181. } catch (e) {
  182. // 静默失败
  183. }
  184. }, []);
  185. const loadData = useCallback(
  186. async (isRefresh = false) => {
  187. if (loading) return;
  188. const page = isRefresh ? 1 : current;
  189. if (!isRefresh && !hasMore) return;
  190. setLoading(true);
  191. try {
  192. const selectedType = typeList[typeIndex];
  193. const res = await getPoolList({
  194. current: page,
  195. size: 10,
  196. mode: selectedType.value || undefined,
  197. type: selectedType.type,
  198. keyword: keyword || undefined,
  199. priceSort: priceSort || undefined,
  200. });
  201. if (res.success && res.data) {
  202. const newList = isRefresh ? res.data : [...list, ...res.data];
  203. setList(newList);
  204. setCurrent(page + 1);
  205. setHasMore(newList.length < (res.count || 0));
  206. }
  207. } catch (error) {
  208. console.error("加载奖池列表失败:", error);
  209. }
  210. setLoading(false);
  211. setRefreshing(false);
  212. },
  213. [current, hasMore, loading, typeIndex, list, keyword, priceSort],
  214. );
  215. useEffect(() => {
  216. loadData(true);
  217. loadBarrage();
  218. }, [typeIndex, priceSort]);
  219. // 对齐 Vue onShow:每次页面获得焦点检查广告弹窗/优惠券
  220. useFocusEffect(
  221. useCallback(() => {
  222. initSuperAd();
  223. initCoupon();
  224. }, [initSuperAd, initCoupon]),
  225. );
  226. // 执行搜索
  227. const handleSearch = () => {
  228. setList([]);
  229. setCurrent(1);
  230. setHasMore(true);
  231. setTimeout(() => {
  232. loadData(true);
  233. }, 100);
  234. };
  235. const handleRefresh = () => {
  236. setRefreshing(true);
  237. loadData(true);
  238. };
  239. const handleLoadMore = () => {
  240. if (!loading && hasMore) {
  241. loadData(false);
  242. }
  243. };
  244. const handleTypeChange = useCallback((index: number) => {
  245. setTypeIndex(index);
  246. setList([]);
  247. setCurrent(1);
  248. setHasMore(true);
  249. }, []);
  250. const handlePriceSort = useCallback(() => {
  251. setPriceSort((prev) => (prev + 1) % 3);
  252. }, []);
  253. const handleItemPress = useCallback(
  254. (item: PoolItem) => {
  255. if (item.status !== undefined && item.status !== 1) return;
  256. if (item.type === 7) {
  257. router.push({
  258. pathname: "/boxInBox",
  259. params: { poolId: item.id },
  260. } as any);
  261. } else if (item.mode === "UNLIMITED") {
  262. router.push({
  263. pathname: "/award-detail",
  264. params: { poolId: item.id },
  265. } as any);
  266. } else if (item.mode === "YFS_PRO") {
  267. router.push({
  268. pathname: "/award-detail-yfs",
  269. params: { poolId: item.id },
  270. } as any);
  271. } else {
  272. router.push(`/product/${item.id}` as any);
  273. }
  274. },
  275. [router],
  276. );
  277. const renderItem = useCallback(
  278. ({ item }: { item: PoolItem }) => (
  279. <TouchableOpacity
  280. style={styles.itemContainer}
  281. onPress={() => handleItemPress(item)}
  282. activeOpacity={0.8}
  283. >
  284. <View style={styles.itemCard}>
  285. <View style={styles.itemImageContainer}>
  286. <Image
  287. source={{ uri: item.cover }}
  288. style={styles.itemImage}
  289. contentFit="cover"
  290. />
  291. {/* Cyber Border Overlay */}
  292. <View style={styles.itemCyberOverlay} />
  293. </View>
  294. <View style={styles.itemInfo}>
  295. <Text style={styles.itemName} numberOfLines={1}>
  296. {item.name}
  297. </Text>
  298. <Text style={styles.itemPrice}>
  299. <Text style={styles.priceUnit}>¥</Text>
  300. {item.price} <Text style={styles.priceStart}>起</Text>
  301. </Text>
  302. </View>
  303. </View>
  304. </TouchableOpacity>
  305. ),
  306. [handleItemPress],
  307. );
  308. const ListHeader = useMemo(
  309. () => (
  310. <View>
  311. <StaticHeader barrageList={barrageList} />
  312. <TypeSelector
  313. typeIndex={typeIndex}
  314. priceSort={priceSort}
  315. onTypeChange={handleTypeChange}
  316. onSortChange={handlePriceSort}
  317. />
  318. </View>
  319. ),
  320. [barrageList, typeIndex, priceSort, handleTypeChange, handlePriceSort],
  321. );
  322. const renderFooter = useCallback(() => {
  323. if (!loading) return null;
  324. return (
  325. <View style={styles.footer}>
  326. <ActivityIndicator size="small" color={Colors.neonBlue} />
  327. <Text style={styles.footerText}>数据传输中...</Text>
  328. </View>
  329. );
  330. }, [loading]);
  331. const renderEmpty = useCallback(() => {
  332. if (loading) return null;
  333. return (
  334. <View style={styles.empty}>
  335. <Text style={styles.emptyText}>暂无数据</Text>
  336. </View>
  337. );
  338. }, [loading]);
  339. return (
  340. <View style={styles.container}>
  341. <StatusBar barStyle="light-content" />
  342. <View style={styles.background}>
  343. {/* Header */}
  344. <View style={[styles.header, { paddingTop: insets.top + 10 }]}>
  345. <View style={styles.logoArea}>
  346. <Text style={styles.logoText}>
  347. CYBER<Text style={styles.logoHighlight}>BOX</Text>
  348. </Text>
  349. </View>
  350. <View style={styles.searchBar}>
  351. <Image
  352. source={{ uri: Images.home.search }}
  353. style={styles.searchIcon}
  354. contentFit="contain"
  355. tintColor={Colors.neonBlue}
  356. />
  357. <TextInput
  358. style={styles.searchInput}
  359. value={keyword}
  360. onChangeText={setKeyword}
  361. placeholder="搜索奖池"
  362. placeholderTextColor={Colors.textTertiary}
  363. returnKeyType="search"
  364. onSubmitEditing={handleSearch}
  365. />
  366. </View>
  367. </View>
  368. {/* List */}
  369. <FlatList
  370. data={list}
  371. renderItem={renderItem}
  372. keyExtractor={(item) => item.id}
  373. ListHeaderComponent={ListHeader}
  374. ListFooterComponent={renderFooter}
  375. ListEmptyComponent={renderEmpty}
  376. contentContainerStyle={styles.listContent}
  377. showsVerticalScrollIndicator={false}
  378. refreshControl={
  379. <RefreshControl
  380. refreshing={refreshing}
  381. onRefresh={handleRefresh}
  382. tintColor={Colors.neonBlue}
  383. />
  384. }
  385. onEndReached={handleLoadMore}
  386. onEndReachedThreshold={0.3}
  387. />
  388. {/* 新人优惠券弹窗 */}
  389. <CouponModal
  390. visible={couponVisible}
  391. coupons={couponList}
  392. onClose={() => setCouponVisible(false)}
  393. onSuccess={() => {
  394. setCouponList([]);
  395. loadData(true);
  396. }}
  397. />
  398. {/* 广告弹窗 (super_ad) */}
  399. {adElements.length > 0 && (
  400. <SuperAdModal
  401. elements={adElements}
  402. onClose={() => setAdElements([])}
  403. />
  404. )}
  405. </View>
  406. </View>
  407. );
  408. }
  409. const styles = StyleSheet.create({
  410. container: {
  411. flex: 1,
  412. backgroundColor: Colors.darkBg,
  413. },
  414. background: {
  415. flex: 1,
  416. backgroundColor: Colors.darkBg,
  417. },
  418. header: {
  419. position: "relative",
  420. zIndex: 11,
  421. flexDirection: "row",
  422. alignItems: "center",
  423. paddingHorizontal: 15,
  424. paddingBottom: 10,
  425. backgroundColor: Colors.darkBg,
  426. },
  427. logoArea: {
  428. marginRight: 15,
  429. },
  430. logoText: {
  431. color: "#fff",
  432. fontWeight: "bold",
  433. fontSize: 18,
  434. fontStyle: "italic",
  435. },
  436. logoHighlight: {
  437. color: Colors.neonPink,
  438. },
  439. searchBar: {
  440. flex: 1,
  441. flexDirection: "row",
  442. alignItems: "center",
  443. backgroundColor: "rgba(255,255,255,0.05)",
  444. borderRadius: 20,
  445. paddingHorizontal: 15,
  446. height: 32,
  447. borderWidth: 1,
  448. borderColor: "rgba(0, 243, 255, 0.3)",
  449. },
  450. searchIcon: {
  451. width: 14,
  452. height: 14,
  453. marginRight: 8,
  454. },
  455. searchInput: {
  456. flex: 1,
  457. color: "#fff",
  458. fontSize: 12,
  459. padding: 0,
  460. },
  461. // Banner
  462. bannerContainer: {
  463. height: 180,
  464. marginHorizontal: 10,
  465. marginTop: 10,
  466. marginBottom: 20,
  467. borderRadius: 12,
  468. overflow: "hidden",
  469. },
  470. cyberBanner: {
  471. flex: 1,
  472. backgroundColor: Colors.darkCard,
  473. justifyContent: "center",
  474. alignItems: "center",
  475. borderWidth: 1,
  476. borderColor: Colors.neonPink,
  477. position: "relative",
  478. },
  479. bannerTitle: {
  480. fontSize: 32,
  481. fontWeight: "bold",
  482. color: "#fff",
  483. textShadowColor: Colors.neonPink,
  484. textShadowRadius: 10,
  485. fontStyle: "italic",
  486. },
  487. bannerSubtitle: {
  488. fontSize: 14,
  489. color: Colors.neonBlue,
  490. letterSpacing: 4,
  491. marginTop: 5,
  492. },
  493. techLine1: {
  494. position: "absolute",
  495. top: 10,
  496. left: 10,
  497. width: 20,
  498. height: 2,
  499. backgroundColor: Colors.neonBlue,
  500. },
  501. techLine2: {
  502. position: "absolute",
  503. bottom: 10,
  504. right: 10,
  505. width: 20,
  506. height: 2,
  507. backgroundColor: Colors.neonBlue,
  508. },
  509. // Type Selector
  510. typeSection: {
  511. flexDirection: "row",
  512. alignItems: "center",
  513. paddingHorizontal: 10,
  514. paddingVertical: 10,
  515. zIndex: 10,
  516. },
  517. typeListContainer: {
  518. flex: 1,
  519. marginRight: 5,
  520. },
  521. typeListContent: {
  522. paddingRight: 10,
  523. },
  524. typeItem: {
  525. paddingHorizontal: 12,
  526. paddingVertical: 6,
  527. borderRadius: 15,
  528. backgroundColor: "rgba(255,255,255,0.05)",
  529. marginRight: 8,
  530. borderWidth: 1,
  531. borderColor: "transparent",
  532. },
  533. typeItemActive: {
  534. backgroundColor: "rgba(0, 243, 255, 0.15)",
  535. borderColor: Colors.neonBlue,
  536. },
  537. typeText: {
  538. color: Colors.textSecondary,
  539. fontSize: 12,
  540. fontWeight: "600",
  541. },
  542. typeTextActive: {
  543. color: "#fff",
  544. textShadowColor: Colors.neonBlue,
  545. textShadowRadius: 5,
  546. },
  547. sortBtn: {
  548. paddingHorizontal: 8,
  549. paddingVertical: 6,
  550. alignItems: "center",
  551. justifyContent: "center",
  552. backgroundColor: "rgba(255,255,255,0.05)",
  553. borderRadius: 15,
  554. },
  555. sortBtnText: {
  556. color: Colors.textTertiary,
  557. fontSize: 12,
  558. },
  559. // List
  560. listContent: {
  561. paddingHorizontal: 10,
  562. paddingBottom: 100,
  563. },
  564. itemContainer: {
  565. marginBottom: 12,
  566. },
  567. itemCard: {
  568. width: "100%",
  569. backgroundColor: Colors.darkCard,
  570. borderRadius: 8,
  571. overflow: "hidden",
  572. borderWidth: 1,
  573. borderColor: "rgba(0, 243, 255, 0.2)",
  574. },
  575. itemImageContainer: {
  576. height: 160,
  577. width: "100%",
  578. position: "relative",
  579. },
  580. itemImage: {
  581. width: "100%",
  582. height: "100%",
  583. },
  584. itemCyberOverlay: {
  585. ...StyleSheet.absoluteFillObject,
  586. backgroundColor: "rgba(0,0,0,0.1)", // Slight dark overlay
  587. borderBottomWidth: 1,
  588. borderBottomColor: Colors.neonBlue,
  589. },
  590. itemInfo: {
  591. flexDirection: "row",
  592. justifyContent: "space-between",
  593. alignItems: "center",
  594. padding: 12,
  595. },
  596. itemName: {
  597. flex: 1,
  598. color: Colors.textPrimary,
  599. fontSize: 14,
  600. fontWeight: "bold",
  601. },
  602. itemPrice: {
  603. color: Colors.neonBlue,
  604. fontSize: 16,
  605. fontWeight: "bold",
  606. marginLeft: 10,
  607. textShadowColor: Colors.neonBlue,
  608. textShadowRadius: 5,
  609. },
  610. priceUnit: {
  611. fontSize: 12,
  612. marginRight: 2,
  613. },
  614. priceStart: {
  615. fontSize: 10,
  616. color: Colors.textTertiary,
  617. fontWeight: "normal",
  618. },
  619. // Footer
  620. footer: {
  621. paddingVertical: 15,
  622. alignItems: "center",
  623. flexDirection: "row",
  624. justifyContent: "center",
  625. },
  626. footerText: {
  627. color: Colors.textTertiary,
  628. fontSize: 12,
  629. marginLeft: 8,
  630. },
  631. empty: {
  632. alignItems: "center",
  633. paddingVertical: 50,
  634. },
  635. emptyText: {
  636. color: Colors.textTertiary,
  637. fontSize: 14,
  638. },
  639. // Barrage
  640. barrageSection: {
  641. marginVertical: 10,
  642. paddingHorizontal: 5, // Edge to edge
  643. },
  644. });