box.tsx 14 KB

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