customer.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { ActivityIndicator, Toast } from '@ant-design/react-native';
  2. import { Ionicons } from '@expo/vector-icons';
  3. import clsx from 'clsx';
  4. import { BlurView } from 'expo-blur';
  5. import { Link, Stack } from 'expo-router';
  6. import React, { useCallback, useEffect, useRef, useState } from 'react';
  7. import { Pressable, Text, TextInput, View } from 'react-native';
  8. import Animated, { Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
  9. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  10. import { StatusBadge } from '@/components/ui/status-badge';
  11. import api from '@/utils/api';
  12. import { getApiCache } from '@/utils/storage';
  13. import type { ListResponse } from '@/utils/api';
  14. export type CustomerLoanStatus = 'idle' | 'matched' | 'unmatch' | 'pending' | 'completed' | 'all' | undefined;
  15. export const CustomerLoanStatusText: Record<NonNullable<CustomerLoanStatus>, string> = {
  16. all: '所有',
  17. idle: '待匹配',
  18. pending: '匹配中',
  19. matched: '已匹配',
  20. completed: '已完成',
  21. unmatch: '匹配失败',
  22. };
  23. export type Customer = {
  24. id: string;
  25. name: string;
  26. mobile: string;
  27. loan_status: CustomerLoanStatus;
  28. note: string;
  29. score?: string;
  30. updatetime: string;
  31. };
  32. const PAGE_SIZE = 15;
  33. const CACHE_KEY = 'customer_first';
  34. function CustomerCard({ item }: { item: Customer }) {
  35. return (
  36. <Pressable
  37. className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4 active:opacity-90 active:scale-[0.99]"
  38. >
  39. <View className="mb-3 flex-row items-start justify-between gap-3">
  40. <View className="flex-1 flex-row items-center gap-3">
  41. <View className="h-11 w-11 items-center justify-center rounded-full bg-primary-fixed">
  42. <Text className="text-base font-bold text-primary">{item.name[0]}</Text>
  43. </View>
  44. <View className="flex-1">
  45. <Text className="text-lg font-bold text-on-surface">{item.name}</Text>
  46. <Text className="mt-1 text-sm text-on-surface-variant">{item.mobile}</Text>
  47. </View>
  48. </View>
  49. <StatusBadge text={item.loan_status || ''} variant="secondary" />
  50. </View>
  51. <Text className="mb-2.5 text-sm leading-6 text-on-surface-variant">{item.note}</Text>
  52. <View className="mb-3 flex-row flex-wrap items-center gap-3">
  53. <View className="flex-row items-center gap-1">
  54. <Ionicons name="document-text-outline" size={16} color="#94a3b8" />
  55. <Text className="text-xs text-on-surface-variant">{item.loan_status}</Text>
  56. </View>
  57. {item.score ? (
  58. <View className="flex-row items-center gap-1">
  59. <Ionicons name="analytics-outline" size={16} color="#94a3b8" />
  60. <Text className="text-xs text-on-surface-variant">评分 {item.score}</Text>
  61. </View>
  62. ) : null}
  63. <Text className="ml-auto text-xs text-outline">{item.updatetime}</Text>
  64. </View>
  65. <View className="flex-row gap-3">
  66. <Pressable
  67. className="flex-1 items-center rounded-xl bg-primary-container py-2.5 active:opacity-88"
  68. >
  69. <Text className="text-sm font-bold text-on-primary">{item.loan_status}</Text>
  70. </Pressable>
  71. <Pressable
  72. className="flex-1 items-center rounded-xl bg-surface-container-high py-2.5 active:opacity-88"
  73. >
  74. <Text className="text-sm font-semibold text-on-surface">编辑资料</Text>
  75. </Pressable>
  76. </View>
  77. </Pressable>
  78. );
  79. }
  80. const renderCustomer = ({ item }: { item: Customer }) => <CustomerCard item={item} />;
  81. const keyExtractor = (item: Customer) => item.id;
  82. export default function CustomerScreens() {
  83. const [searchKey, setSearchKey] = useState('');
  84. const [list, setList] = useState<Customer[]>([]);
  85. const [loading, setLoading] = useState(true);
  86. const [activeStatus, setActiveStatus] = useState<CustomerLoanStatus>('idle');
  87. const startRef = useRef(0);
  88. const loanStatusRef = useRef<CustomerLoanStatus>('idle');
  89. const hasMoreRef = useRef(false);
  90. const loadingRef = useRef(false);
  91. const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  92. const insets = useSafeAreaInsets();
  93. const scrollOffsetY = useSharedValue(0);
  94. const scrollHandler = useAnimatedScrollHandler((e) => {
  95. scrollOffsetY.value = e.contentOffset.y;
  96. });
  97. const headerStyle = useAnimatedStyle(() => ({
  98. opacity: interpolate(scrollOffsetY.value, [0, 24 + insets.top], [0, 1], Extrapolation.CLAMP),
  99. }));
  100. const load = useCallback(async (start: number, loanStatus: CustomerLoanStatus, q?: string) => {
  101. if (loadingRef.current) return;
  102. loadingRef.current = true;
  103. loanStatusRef.current = loanStatus;
  104. startRef.current = start;
  105. setLoading(true);
  106. if (start === 0 && !loanStatus && !q) {
  107. const cache = getApiCache().getObject<ListResponse<Customer>>(CACHE_KEY);
  108. if (cache) {
  109. setList(cache.list);
  110. startRef.current = cache.list.length;
  111. hasMoreRef.current = cache.list.length >= PAGE_SIZE;
  112. setLoading(false);
  113. loadingRef.current = false;
  114. return;
  115. }
  116. }
  117. try {
  118. const res = await api.post<ListResponse<Customer>>('customer/list', {
  119. start,
  120. size: PAGE_SIZE,
  121. loanStatus,
  122. q,
  123. });
  124. if (loanStatusRef.current !== loanStatus) return;
  125. const next = res?.list ?? [];
  126. if (start === 0 && !loanStatus && !q && next.length) {
  127. getApiCache().setObject(CACHE_KEY, res, 60);
  128. }
  129. hasMoreRef.current = next.length >= PAGE_SIZE;
  130. setList((prev) => (start === 0 ? next : prev.concat(next)));
  131. startRef.current = start + next.length;
  132. if (!next.length && start > 0) {
  133. Toast.offline('没有更多数据可加载');
  134. }
  135. } catch {
  136. Toast.fail('加载列表失败!');
  137. } finally {
  138. setLoading(false);
  139. loadingRef.current = false;
  140. }
  141. }, []);
  142. useEffect(() => {
  143. load(0, 'idle');
  144. }, [load]);
  145. const loadMore = useCallback(() => {
  146. if (!hasMoreRef.current || loadingRef.current) return;
  147. load(startRef.current, loanStatusRef.current, searchKey);
  148. }, [load, searchKey]);
  149. const handleSelectStatus = useCallback(
  150. (status: CustomerLoanStatus) => {
  151. const next = activeStatus === status ? undefined : status;
  152. setActiveStatus(next);
  153. load(0, next, searchKey);
  154. },
  155. [activeStatus, load, searchKey]
  156. );
  157. const handleSearch = useCallback(
  158. (k: string) => {
  159. setSearchKey(k);
  160. if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
  161. searchTimerRef.current = setTimeout(() => {
  162. load(0, loanStatusRef.current, k);
  163. }, 600);
  164. },
  165. [load]
  166. );
  167. const handleReset = useCallback(() => {
  168. handleSearch('');
  169. }, [handleSearch]);
  170. useEffect(() => {
  171. return () => {
  172. if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
  173. };
  174. }, []);
  175. const ListHeader = (
  176. <>
  177. <Text className="h-[44px] text-3xl font-extrabold tracking-tight text-on-surface">客户</Text>
  178. <Text className="mb-5 text-base leading-7 text-on-surface-variant">
  179. 统一跟进客户资料、征信进度和产品匹配状态
  180. </Text>
  181. <View className="mb-3 flex-row items-center rounded-2xl bg-surface-container-low px-4 py-3">
  182. <Ionicons name="search-outline" size={20} color="#94a3b8" />
  183. <TextInput
  184. value={searchKey}
  185. onChangeText={handleSearch}
  186. placeholder="搜索客户姓名 / 手机号"
  187. placeholderTextColor="#94a3b8"
  188. className="ml-3 flex-1 p-0 text-base text-on-surface"
  189. />
  190. {searchKey.length > 0 ? (
  191. <Pressable hitSlop={8} onPress={handleReset}>
  192. <Ionicons name="close-circle" size={20} color="#94a3b8" />
  193. </Pressable>
  194. ) : null}
  195. </View>
  196. <View className="mb-5 flex-row flex-wrap gap-2">
  197. {Object.entries(CustomerLoanStatusText).map(([key, label]) => {
  198. const active = activeStatus === (key as CustomerLoanStatus);
  199. return (
  200. <Pressable
  201. key={key}
  202. onPress={() => handleSelectStatus(key as CustomerLoanStatus)}
  203. className={clsx('rounded-md px-1 border border-transparent active:opacity-84', {
  204. 'border-primary-container': active,
  205. })}
  206. >
  207. <Text
  208. className={clsx('text-sm font-bold text-on-surface-variant', {
  209. 'text-primary': active,
  210. })}
  211. >
  212. {label}
  213. </Text>
  214. </Pressable>
  215. );
  216. })}
  217. </View>
  218. </>
  219. );
  220. const ListEmpty = !loading ? (
  221. <View className="items-center rounded-2xl border border-outline-variant bg-surface-container-lowest px-6 py-14">
  222. <Ionicons name="people-outline" size={44} color="#94a3b8" />
  223. <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
  224. </View>
  225. ) : null;
  226. const ListFooter =
  227. loading && list.length > 0 ? (
  228. <View className="flex-row justify-center items-center mt-2">
  229. <ActivityIndicator />
  230. <Text className="ml-2 pb-4">加载中</Text>
  231. </View>
  232. ) : null;
  233. const ListInitialLoading =
  234. loading && list.length === 0 ? (
  235. <View className="flex-row justify-center items-center mt-2">
  236. <ActivityIndicator />
  237. <Text className="ml-2 pb-4">加载中</Text>
  238. </View>
  239. ) : null;
  240. return (
  241. <>
  242. <Animated.FlatList
  243. className="flex-1"
  244. // contentInset={{ top: insets.top, bottom: insets.bottom }}
  245. automaticallyAdjustContentInsets
  246. contentContainerStyle={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 44 + 20, paddingHorizontal: 20 }}
  247. data={list}
  248. keyExtractor={keyExtractor}
  249. renderItem={renderCustomer}
  250. keyboardShouldPersistTaps="handled"
  251. showsVerticalScrollIndicator={false}
  252. ListHeaderComponent={ListHeader}
  253. ListEmptyComponent={ListInitialLoading ?? ListEmpty}
  254. ListFooterComponent={ListFooter}
  255. onEndReached={loadMore}
  256. onEndReachedThreshold={0.5}
  257. onScroll={scrollHandler}
  258. scrollEventThrottle={16}
  259. />
  260. <Stack.Screen options={{
  261. headerShown: true,
  262. headerTransparent: true,
  263. header: () => (
  264. <Animated.View style={headerStyle} className="border-b border-outline-variant/60 android:bg-surface-container-lowest/90">
  265. <BlurView tint="light" className="bg-background">
  266. <Text style={{ marginTop: insets.top }} className="pl-5 h-[44px] text-3xl font-extrabold tracking-tight text-on-surface">
  267. 客户
  268. </Text>
  269. </BlurView>
  270. </Animated.View>
  271. ),
  272. }} />
  273. <Link asChild href="/customer/add" style={{bottom: insets.bottom + 44 + 12}} className="absolute pb-2 right-5">
  274. <Pressable
  275. className="h-14 w-14 items-center justify-center rounded-full bg-primary-container active:opacity-80 active:scale-[0.94]"
  276. >
  277. <Ionicons name="person-add" size={20} color="#ffffff" />
  278. </Pressable>
  279. </Link>
  280. </>
  281. );
  282. }