| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- import { ActivityIndicator, Toast } from '@ant-design/react-native';
- import { Ionicons } from '@expo/vector-icons';
- import clsx from 'clsx';
- import { BlurView } from 'expo-blur';
- import { Link, Stack } from 'expo-router';
- import React, { useCallback, useEffect, useRef, useState } from 'react';
- import { Pressable, Text, TextInput, View } from 'react-native';
- import Animated, { Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
- import { StatusBadge } from '@/components/ui/status-badge';
- import api from '@/utils/api';
- import { getApiCache } from '@/utils/storage';
- import type { ListResponse } from '@/utils/api';
- export type CustomerLoanStatus = 'idle' | 'matched' | 'unmatch' | 'pending' | 'completed' | 'all' | undefined;
- export const CustomerLoanStatusText: Record<NonNullable<CustomerLoanStatus>, string> = {
- all: '所有',
- idle: '待匹配',
- pending: '匹配中',
- matched: '已匹配',
- completed: '已完成',
- unmatch: '匹配失败',
- };
- export type Customer = {
- id: string;
- name: string;
- mobile: string;
- loan_status: CustomerLoanStatus;
- note: string;
- score?: string;
- updatetime: string;
- };
- const PAGE_SIZE = 15;
- const CACHE_KEY = 'customer_first';
- function CustomerCard({ item }: { item: Customer }) {
- return (
- <Pressable
- className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4 active:opacity-90 active:scale-[0.99]"
- >
- <View className="mb-3 flex-row items-start justify-between gap-3">
- <View className="flex-1 flex-row items-center gap-3">
- <View className="h-11 w-11 items-center justify-center rounded-full bg-primary-fixed">
- <Text className="text-base font-bold text-primary">{item.name[0]}</Text>
- </View>
- <View className="flex-1">
- <Text className="text-lg font-bold text-on-surface">{item.name}</Text>
- <Text className="mt-1 text-sm text-on-surface-variant">{item.mobile}</Text>
- </View>
- </View>
- <StatusBadge text={item.loan_status || ''} variant="secondary" />
- </View>
- <Text className="mb-2.5 text-sm leading-6 text-on-surface-variant">{item.note}</Text>
- <View className="mb-3 flex-row flex-wrap items-center gap-3">
- <View className="flex-row items-center gap-1">
- <Ionicons name="document-text-outline" size={16} color="#94a3b8" />
- <Text className="text-xs text-on-surface-variant">{item.loan_status}</Text>
- </View>
- {item.score ? (
- <View className="flex-row items-center gap-1">
- <Ionicons name="analytics-outline" size={16} color="#94a3b8" />
- <Text className="text-xs text-on-surface-variant">评分 {item.score}</Text>
- </View>
- ) : null}
- <Text className="ml-auto text-xs text-outline">{item.updatetime}</Text>
- </View>
- <View className="flex-row gap-3">
- <Pressable
- className="flex-1 items-center rounded-xl bg-primary-container py-2.5 active:opacity-88"
- >
- <Text className="text-sm font-bold text-on-primary">{item.loan_status}</Text>
- </Pressable>
- <Pressable
- className="flex-1 items-center rounded-xl bg-surface-container-high py-2.5 active:opacity-88"
- >
- <Text className="text-sm font-semibold text-on-surface">编辑资料</Text>
- </Pressable>
- </View>
- </Pressable>
- );
- }
- const renderCustomer = ({ item }: { item: Customer }) => <CustomerCard item={item} />;
- const keyExtractor = (item: Customer) => item.id;
- export default function CustomerScreens() {
- const [searchKey, setSearchKey] = useState('');
- const [list, setList] = useState<Customer[]>([]);
- const [loading, setLoading] = useState(true);
- const [activeStatus, setActiveStatus] = useState<CustomerLoanStatus>('idle');
- const startRef = useRef(0);
- const loanStatusRef = useRef<CustomerLoanStatus>('idle');
- const hasMoreRef = useRef(false);
- const loadingRef = useRef(false);
- const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- const insets = useSafeAreaInsets();
- const scrollOffsetY = useSharedValue(0);
- const scrollHandler = useAnimatedScrollHandler((e) => {
- scrollOffsetY.value = e.contentOffset.y;
- });
- const headerStyle = useAnimatedStyle(() => ({
- opacity: interpolate(scrollOffsetY.value, [0, 24 + insets.top], [0, 1], Extrapolation.CLAMP),
- }));
- const load = useCallback(async (start: number, loanStatus: CustomerLoanStatus, q?: string) => {
- if (loadingRef.current) return;
- loadingRef.current = true;
- loanStatusRef.current = loanStatus;
- startRef.current = start;
- setLoading(true);
- if (start === 0 && !loanStatus && !q) {
- const cache = getApiCache().getObject<ListResponse<Customer>>(CACHE_KEY);
- if (cache) {
- setList(cache.list);
- startRef.current = cache.list.length;
- hasMoreRef.current = cache.list.length >= PAGE_SIZE;
- setLoading(false);
- loadingRef.current = false;
- return;
- }
- }
- try {
- const res = await api.post<ListResponse<Customer>>('customer/list', {
- start,
- size: PAGE_SIZE,
- loanStatus,
- q,
- });
- if (loanStatusRef.current !== loanStatus) return;
- const next = res?.list ?? [];
- if (start === 0 && !loanStatus && !q && next.length) {
- getApiCache().setObject(CACHE_KEY, res, 60);
- }
- hasMoreRef.current = next.length >= PAGE_SIZE;
- setList((prev) => (start === 0 ? next : prev.concat(next)));
- startRef.current = start + next.length;
- if (!next.length && start > 0) {
- Toast.offline('没有更多数据可加载');
- }
- } catch {
- Toast.fail('加载列表失败!');
- } finally {
- setLoading(false);
- loadingRef.current = false;
- }
- }, []);
- useEffect(() => {
- load(0, 'idle');
- }, [load]);
- const loadMore = useCallback(() => {
- if (!hasMoreRef.current || loadingRef.current) return;
- load(startRef.current, loanStatusRef.current, searchKey);
- }, [load, searchKey]);
- const handleSelectStatus = useCallback(
- (status: CustomerLoanStatus) => {
- const next = activeStatus === status ? undefined : status;
- setActiveStatus(next);
- load(0, next, searchKey);
- },
- [activeStatus, load, searchKey]
- );
- const handleSearch = useCallback(
- (k: string) => {
- setSearchKey(k);
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
- searchTimerRef.current = setTimeout(() => {
- load(0, loanStatusRef.current, k);
- }, 600);
- },
- [load]
- );
- const handleReset = useCallback(() => {
- handleSearch('');
- }, [handleSearch]);
- useEffect(() => {
- return () => {
- if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
- };
- }, []);
- const ListHeader = (
- <>
- <Text className="h-[44px] text-3xl font-extrabold tracking-tight text-on-surface">客户</Text>
- <Text className="mb-5 text-base leading-7 text-on-surface-variant">
- 统一跟进客户资料、征信进度和产品匹配状态
- </Text>
- <View className="mb-3 flex-row items-center rounded-2xl bg-surface-container-low px-4 py-3">
- <Ionicons name="search-outline" size={20} color="#94a3b8" />
- <TextInput
- value={searchKey}
- onChangeText={handleSearch}
- placeholder="搜索客户姓名 / 手机号"
- placeholderTextColor="#94a3b8"
- className="ml-3 flex-1 p-0 text-base text-on-surface"
- />
- {searchKey.length > 0 ? (
- <Pressable hitSlop={8} onPress={handleReset}>
- <Ionicons name="close-circle" size={20} color="#94a3b8" />
- </Pressable>
- ) : null}
- </View>
- <View className="mb-5 flex-row flex-wrap gap-2">
- {Object.entries(CustomerLoanStatusText).map(([key, label]) => {
- const active = activeStatus === (key as CustomerLoanStatus);
- return (
- <Pressable
- key={key}
- onPress={() => handleSelectStatus(key as CustomerLoanStatus)}
- className={clsx('rounded-md px-1 border border-transparent active:opacity-84', {
- 'border-primary-container': active,
- })}
- >
- <Text
- className={clsx('text-sm font-bold text-on-surface-variant', {
- 'text-primary': active,
- })}
- >
- {label}
- </Text>
- </Pressable>
- );
- })}
- </View>
- </>
- );
- const ListEmpty = !loading ? (
- <View className="items-center rounded-2xl border border-outline-variant bg-surface-container-lowest px-6 py-14">
- <Ionicons name="people-outline" size={44} color="#94a3b8" />
- <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
- </View>
- ) : null;
- const ListFooter =
- loading && list.length > 0 ? (
- <View className="flex-row justify-center items-center mt-2">
- <ActivityIndicator />
- <Text className="ml-2 pb-4">加载中</Text>
- </View>
- ) : null;
- const ListInitialLoading =
- loading && list.length === 0 ? (
- <View className="flex-row justify-center items-center mt-2">
- <ActivityIndicator />
- <Text className="ml-2 pb-4">加载中</Text>
- </View>
- ) : null;
- return (
- <>
- <Animated.FlatList
- className="flex-1"
- // contentInset={{ top: insets.top, bottom: insets.bottom }}
- automaticallyAdjustContentInsets
- contentContainerStyle={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 44 + 20, paddingHorizontal: 20 }}
- data={list}
- keyExtractor={keyExtractor}
- renderItem={renderCustomer}
- keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
- ListHeaderComponent={ListHeader}
- ListEmptyComponent={ListInitialLoading ?? ListEmpty}
- ListFooterComponent={ListFooter}
- onEndReached={loadMore}
- onEndReachedThreshold={0.5}
- onScroll={scrollHandler}
- scrollEventThrottle={16}
- />
- <Stack.Screen options={{
- headerShown: true,
- headerTransparent: true,
- header: () => (
- <Animated.View style={headerStyle} className="border-b border-outline-variant/60 android:bg-surface-container-lowest/90">
- <BlurView tint="light" className="bg-background">
- <Text style={{ marginTop: insets.top }} className="pl-5 h-[44px] text-3xl font-extrabold tracking-tight text-on-surface">
- 客户
- </Text>
- </BlurView>
- </Animated.View>
- ),
- }} />
- <Link asChild href="/customer/add" style={{bottom: insets.bottom + 44 + 12}} className="absolute pb-2 right-5">
- <Pressable
- className="h-14 w-14 items-center justify-center rounded-full bg-primary-container active:opacity-80 active:scale-[0.94]"
- >
- <Ionicons name="person-add" size={20} color="#ffffff" />
- </Pressable>
- </Link>
- </>
- );
- }
|