customer.tsx 11 KB

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