box.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import { Barrage } from '@/components/Barrage';
  2. import { Image } from 'expo-image';
  3. import { useRouter } from 'expo-router';
  4. import React, { useCallback, useEffect, useMemo, useState } from 'react';
  5. import {
  6. ActivityIndicator,
  7. FlatList,
  8. ImageBackground,
  9. RefreshControl,
  10. ScrollView,
  11. StatusBar,
  12. StyleSheet,
  13. Text,
  14. TextInput,
  15. TouchableOpacity,
  16. View
  17. } from 'react-native';
  18. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  19. import { Images } from '@/constants/images';
  20. import { getFeedbackList, getPoolList, PoolItem } from '@/services/award';
  21. const typeList = [
  22. { label: '全部', value: '', type: 0, img: Images.box.type1, imgOn: Images.box.type1On },
  23. { label: '高保赏', value: 'UNLIMITED', type: 2, img: Images.box.type3, imgOn: Images.box.type3On },
  24. { label: '高爆赏', value: 'UNLIMITED', type: 1, img: Images.box.type2, imgOn: Images.box.type2On },
  25. { label: '擂台赏', value: 'YFS_PRO', type: 7, img: Images.box.type5, imgOn: Images.box.type5On },
  26. { label: '一番赏', value: 'YFS_PRO', type: 0, img: Images.box.type4, imgOn: Images.box.type4On },
  27. ];
  28. interface BarrageItem {
  29. id: string;
  30. content: string;
  31. nickname?: string;
  32. avatar: string;
  33. poolName?: string;
  34. type?: string;
  35. text?: string;
  36. poolId?: string;
  37. }
  38. // Static Header Component - Memoized to prevent re-renders
  39. const StaticHeader = React.memo(({ barrageList }: { barrageList: BarrageItem[] }) => (
  40. <View>
  41. {/* 占位空间 - 给顶部搜索栏留出空间 */}
  42. <View style={{ height: 53 }} />
  43. {/* 顶部主图 - 绝对定位,叠在背景上 */}
  44. <View style={styles.mainImageContainer}>
  45. <Image
  46. source={{ uri: Images.box.awardMainImg }}
  47. style={styles.mainImage}
  48. contentFit="fill"
  49. />
  50. </View>
  51. {/* 占位空间 - 主图高度 */}
  52. <View style={{ height: 360 }} />
  53. {/* 弹幕区域 */}
  54. {barrageList && barrageList.length > 0 && (
  55. <View style={styles.barrageSection}>
  56. <Barrage data={barrageList.slice(0, Math.ceil(barrageList.length / 2))} />
  57. <View style={{ height: 6 }} />
  58. <Barrage
  59. data={barrageList.slice(Math.ceil(barrageList.length / 2))}
  60. speed={35}
  61. />
  62. </View>
  63. )}
  64. </View>
  65. ));
  66. // Type Selector Component
  67. const TypeSelector = React.memo(({
  68. typeIndex,
  69. priceSort,
  70. onTypeChange,
  71. onSortChange
  72. }: {
  73. typeIndex: number;
  74. priceSort: number;
  75. onTypeChange: (index: number) => void;
  76. onSortChange: () => void;
  77. }) => (
  78. <View style={styles.typeSection}>
  79. <View style={styles.typeListContainer}>
  80. <ScrollView
  81. horizontal
  82. showsHorizontalScrollIndicator={false}
  83. contentContainerStyle={styles.typeListContent}
  84. >
  85. {typeList.map((item, index) => (
  86. <TouchableOpacity
  87. key={index}
  88. style={styles.typeItem}
  89. onPress={() => onTypeChange(index)}
  90. activeOpacity={0.7}
  91. >
  92. <Image
  93. source={{ uri: typeIndex === index ? item.imgOn : item.img }}
  94. style={styles.typeImage}
  95. contentFit="fill"
  96. />
  97. </TouchableOpacity>
  98. ))}
  99. </ScrollView>
  100. </View>
  101. <TouchableOpacity style={styles.sortBtn} onPress={onSortChange} activeOpacity={0.7}>
  102. <Image
  103. source={{
  104. uri: priceSort === 0
  105. ? Images.box.sortAmount
  106. : priceSort === 1
  107. ? Images.box.sortAmountOnT
  108. : Images.box.sortAmountOnB,
  109. }}
  110. style={styles.sortIcon}
  111. contentFit="contain"
  112. />
  113. </TouchableOpacity>
  114. </View>
  115. ));
  116. export default function BoxScreen() {
  117. const router = useRouter();
  118. const insets = useSafeAreaInsets();
  119. const [keyword, setKeyword] = useState('');
  120. const [typeIndex, setTypeIndex] = useState(0);
  121. const [priceSort, setPriceSort] = useState(0);
  122. const [list, setList] = useState<PoolItem[]>([]);
  123. const [loading, setLoading] = useState(false);
  124. const [refreshing, setRefreshing] = useState(false);
  125. const [current, setCurrent] = useState(1);
  126. const [total, setTotal] = useState(0);
  127. const [hasMore, setHasMore] = useState(true);
  128. const [barrageList, setBarrageList] = useState<BarrageItem[]>([]);
  129. // 加载弹幕
  130. const loadBarrage = useCallback(async () => {
  131. try {
  132. const res = await getFeedbackList();
  133. if (res.data) {
  134. setBarrageList(res.data);
  135. }
  136. } catch (error) {
  137. console.error('加载弹幕失败:', error);
  138. }
  139. }, []);
  140. const loadData = useCallback(async (isRefresh = false) => {
  141. if (loading) return;
  142. const page = isRefresh ? 1 : current;
  143. if (!isRefresh && !hasMore) return;
  144. setLoading(true);
  145. try {
  146. const selectedType = typeList[typeIndex];
  147. const res = await getPoolList({
  148. current: page,
  149. size: 10,
  150. mode: selectedType.value || undefined,
  151. type: selectedType.type,
  152. keyword: keyword || undefined,
  153. priceSort: priceSort || undefined,
  154. });
  155. if (res.success && res.data) {
  156. const newList = isRefresh ? res.data : [...list, ...res.data];
  157. setList(newList);
  158. setTotal(res.count || 0);
  159. setCurrent(page + 1);
  160. setHasMore(newList.length < (res.count || 0));
  161. }
  162. } catch (error) {
  163. console.error('加载奖池列表失败:', error);
  164. }
  165. setLoading(false);
  166. setRefreshing(false);
  167. }, [current, hasMore, loading, typeIndex, list, keyword, priceSort]);
  168. useEffect(() => {
  169. loadData(true);
  170. loadBarrage();
  171. }, [typeIndex, priceSort]);
  172. // 执行搜索
  173. const handleSearch = () => {
  174. setList([]);
  175. setCurrent(1);
  176. setHasMore(true);
  177. // 需要延迟一下让状态更新
  178. setTimeout(() => {
  179. loadData(true);
  180. }, 100);
  181. };
  182. const handleRefresh = () => {
  183. setRefreshing(true);
  184. loadData(true);
  185. };
  186. const handleLoadMore = () => {
  187. if (!loading && hasMore) {
  188. loadData(false);
  189. }
  190. };
  191. const handleTypeChange = useCallback((index: number) => {
  192. setTypeIndex(index);
  193. setList([]);
  194. setCurrent(1);
  195. setHasMore(true);
  196. }, []);
  197. const handlePriceSort = useCallback(() => {
  198. setPriceSort((prev) => (prev + 1) % 3);
  199. }, []);
  200. const handleItemPress = useCallback((item: PoolItem) => {
  201. // 检查商品状态
  202. if (item.status !== undefined && item.status !== 1) return;
  203. console.log('点击商品:', item.id, 'mode:', item.mode, 'type:', item.type);
  204. // 根据类型跳转到不同页面 - 按照小程序逻辑
  205. if (item.type === 7) {
  206. // 擂台赏跳转到 boxInBox 页面
  207. router.push({ pathname: '/boxInBox', params: { poolId: item.id } } as any);
  208. } else if (item.mode === 'UNLIMITED') {
  209. // 高爆赏/高保赏
  210. router.push({ pathname: '/award-detail', params: { poolId: item.id } } as any);
  211. } else if (item.mode === 'YFS_PRO') {
  212. // 一番赏
  213. router.push({ pathname: '/award-detail-yfs', params: { poolId: item.id } } as any);
  214. } else {
  215. // 其他商品
  216. router.push(`/product/${item.id}` as any);
  217. }
  218. }, [router]);
  219. const renderItem = useCallback(({ item }: { item: PoolItem }) => (
  220. <TouchableOpacity
  221. style={styles.itemContainer}
  222. onPress={() => handleItemPress(item)}
  223. activeOpacity={0.8}
  224. >
  225. <ImageBackground
  226. source={{ uri: Images.box.goodsItemBg }}
  227. style={styles.itemBg}
  228. resizeMode="stretch"
  229. >
  230. <Image
  231. source={{ uri: item.cover }}
  232. style={styles.itemImage}
  233. contentFit="cover"
  234. />
  235. <View style={styles.itemInfo}>
  236. <Text style={styles.itemName} numberOfLines={1}>{item.name}</Text>
  237. <Text style={styles.itemPrice}>
  238. <Text style={styles.priceUnit}>¥</Text>
  239. {item.price}起
  240. </Text>
  241. </View>
  242. </ImageBackground>
  243. </TouchableOpacity>
  244. ), [handleItemPress]);
  245. const ListHeader = useMemo(() => (
  246. <View>
  247. <StaticHeader barrageList={barrageList} />
  248. <TypeSelector
  249. typeIndex={typeIndex}
  250. priceSort={priceSort}
  251. onTypeChange={handleTypeChange}
  252. onSortChange={handlePriceSort}
  253. />
  254. </View>
  255. ), [barrageList, typeIndex, priceSort, handleTypeChange, handlePriceSort]);
  256. const renderFooter = useCallback(() => {
  257. if (!loading) return null;
  258. return (
  259. <View style={styles.footer}>
  260. <ActivityIndicator size="small" color="#fff" />
  261. <Text style={styles.footerText}>加载中...</Text>
  262. </View>
  263. );
  264. }, [loading]);
  265. const renderEmpty = useCallback(() => {
  266. if (loading) return null;
  267. return (
  268. <View style={styles.empty}>
  269. <Text style={styles.emptyText}>暂无数据</Text>
  270. </View>
  271. );
  272. }, [loading]);
  273. return (
  274. <View style={styles.container}>
  275. <StatusBar barStyle="light-content" />
  276. <ImageBackground
  277. source={{ uri: Images.common.indexBg }}
  278. style={styles.background}
  279. resizeMode="cover"
  280. >
  281. {/* 顶部搜索栏 */}
  282. <View style={[styles.header, { paddingTop: insets.top + 10 }]}>
  283. <Image
  284. source={{ uri: Images.home.portrait }}
  285. style={styles.logo}
  286. contentFit="contain"
  287. />
  288. <View style={styles.searchBar}>
  289. <Image
  290. source={{ uri: Images.home.search }}
  291. style={styles.searchIcon}
  292. contentFit="contain"
  293. />
  294. <TextInput
  295. style={styles.searchInput}
  296. value={keyword}
  297. onChangeText={setKeyword}
  298. placeholder="搜索"
  299. placeholderTextColor="rgba(255,255,255,0.5)"
  300. returnKeyType="search"
  301. onSubmitEditing={handleSearch}
  302. />
  303. {keyword.length > 0 && (
  304. <TouchableOpacity onPress={handleSearch} style={styles.searchBtn}>
  305. <Text style={styles.searchBtnText}>搜索</Text>
  306. </TouchableOpacity>
  307. )}
  308. </View>
  309. </View>
  310. {/* 列表 */}
  311. <FlatList
  312. data={list}
  313. renderItem={renderItem}
  314. keyExtractor={(item) => item.id}
  315. ListHeaderComponent={ListHeader}
  316. ListFooterComponent={renderFooter}
  317. ListEmptyComponent={renderEmpty}
  318. contentContainerStyle={styles.listContent}
  319. showsVerticalScrollIndicator={false}
  320. refreshControl={
  321. <RefreshControl
  322. refreshing={refreshing}
  323. onRefresh={handleRefresh}
  324. tintColor="#fff"
  325. />
  326. }
  327. onEndReached={handleLoadMore}
  328. onEndReachedThreshold={0.3}
  329. />
  330. </ImageBackground>
  331. </View>
  332. );
  333. }
  334. const styles = StyleSheet.create({
  335. container: {
  336. flex: 1,
  337. backgroundColor: '#1a1a2e',
  338. },
  339. background: {
  340. flex: 1,
  341. },
  342. header: {
  343. position: 'relative',
  344. zIndex: 11,
  345. flexDirection: 'row',
  346. alignItems: 'center',
  347. paddingHorizontal: 15,
  348. paddingBottom: 10,
  349. },
  350. logo: {
  351. width: 67,
  352. height: 25,
  353. marginRight: 20,
  354. },
  355. searchBar: {
  356. flex: 1,
  357. flexDirection: 'row',
  358. alignItems: 'center',
  359. backgroundColor: 'rgba(255,255,255,0.38)',
  360. borderRadius: 180,
  361. paddingHorizontal: 15,
  362. height: 28,
  363. },
  364. searchIcon: {
  365. width: 15,
  366. height: 15,
  367. marginRight: 5,
  368. },
  369. searchInput: {
  370. flex: 1,
  371. color: '#fff',
  372. fontSize: 12,
  373. padding: 0,
  374. },
  375. searchBtn: {
  376. paddingHorizontal: 8,
  377. paddingVertical: 2,
  378. },
  379. searchBtnText: {
  380. color: '#fff',
  381. fontSize: 12,
  382. },
  383. mainImageContainer: {
  384. position: 'absolute',
  385. left: 0,
  386. top: 0,
  387. zIndex: 2,
  388. width: '100%',
  389. height: 395,
  390. },
  391. mainImage: {
  392. width: '100%',
  393. height: '100%',
  394. },
  395. typeSection: {
  396. flexDirection: 'row',
  397. alignItems: 'center',
  398. paddingHorizontal: 10,
  399. paddingVertical: 10,
  400. position: 'relative',
  401. zIndex: 10,
  402. backgroundColor: 'transparent',
  403. },
  404. typeListContainer: {
  405. flex: 1,
  406. marginRight: 10,
  407. },
  408. typeListContent: {
  409. paddingRight: 10,
  410. },
  411. typeItem: {
  412. width: 73,
  413. height: 34,
  414. marginRight: 4,
  415. },
  416. typeImage: {
  417. width: '100%',
  418. height: '100%',
  419. },
  420. sortBtn: {
  421. width: 38,
  422. height: 38,
  423. alignItems: 'center',
  424. justifyContent: 'center',
  425. },
  426. sortIcon: {
  427. width: 38,
  428. height: 38,
  429. },
  430. listContent: {
  431. paddingHorizontal: 10,
  432. paddingBottom: 100,
  433. },
  434. itemContainer: {
  435. marginBottom: 8,
  436. },
  437. itemBg: {
  438. width: '100%',
  439. height: 230, // Increased total height slightly for safety
  440. // Remove padding to control children individually
  441. },
  442. itemImage: {
  443. width: '95%', // Very tight fit (leaving ~2.5% gap on sides = small gap)
  444. height: 162, // expand height downwards
  445. borderRadius: 6,
  446. alignSelf: 'center',
  447. marginTop: 13, // Tighter top fit (approx 1px-2px visual gap)
  448. },
  449. itemInfo: {
  450. flexDirection: 'row',
  451. justifyContent: 'space-between',
  452. alignItems: 'center',
  453. paddingHorizontal: 20, // Visual padding for text
  454. paddingTop: 18, // Move text downwards
  455. },
  456. itemName: {
  457. flex: 1,
  458. color: '#fff',
  459. fontSize: 14,
  460. },
  461. itemPrice: {
  462. color: '#ff0000',
  463. fontSize: 12,
  464. fontWeight: 'bold',
  465. marginLeft: 10,
  466. },
  467. priceUnit: {
  468. fontSize: 12,
  469. marginRight: 2,
  470. },
  471. footer: {
  472. flexDirection: 'row',
  473. justifyContent: 'center',
  474. alignItems: 'center',
  475. paddingVertical: 15,
  476. },
  477. footerText: {
  478. color: 'rgba(255,255,255,0.6)',
  479. fontSize: 12,
  480. marginLeft: 8,
  481. },
  482. empty: {
  483. alignItems: 'center',
  484. paddingVertical: 50,
  485. },
  486. emptyText: {
  487. color: 'rgba(255,255,255,0.6)',
  488. fontSize: 14,
  489. },
  490. barrageSection: {
  491. marginVertical: 10,
  492. paddingHorizontal: 10,
  493. },
  494. barrageRow: {
  495. flexDirection: 'row',
  496. marginBottom: 5,
  497. },
  498. barrageItem: {
  499. backgroundColor: 'rgba(0,0,0,0.5)',
  500. borderRadius: 15,
  501. paddingHorizontal: 12,
  502. paddingVertical: 6,
  503. marginRight: 8,
  504. maxWidth: 150,
  505. },
  506. barrageText: {
  507. color: '#fff',
  508. fontSize: 12,
  509. },
  510. });