lv vor 1 Monat
Ursprung
Commit
b7d222091c

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
     "@react-navigation/native": "^7.1.33",
     "axios": "^1.14.0",
     "clsx": "^2.1.1",
+    "dayjs": "^1.11.20",
     "expo": "~55.0.12",
     "expo-application": "~55.0.14",
     "expo-blur": "~55.0.14",

+ 4 - 17
pnpm-lock.yaml

@@ -34,6 +34,9 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      dayjs:
+        specifier: ^1.11.20
+        version: 1.11.20
       expo:
         specifier: ~55.0.12
         version: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.11)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -1762,49 +1765,41 @@ packages:
     resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
     resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
     resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
     cpu: [ppc64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
     resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
     cpu: [riscv64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
     resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
     cpu: [riscv64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
     resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
     cpu: [s390x]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
     resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   '@unrs/resolver-binding-linux-x64-musl@1.11.1':
     resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   '@unrs/resolver-binding-wasm32-wasi@1.11.1':
     resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3107,7 +3102,7 @@ packages:
 
   glob@7.2.3:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   globals@14.0.0:
     resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
@@ -3626,56 +3621,48 @@ packages:
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-arm64-gnu@1.32.0:
     resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-arm64-musl@1.27.0:
     resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-linux-arm64-musl@1.32.0:
     resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-linux-x64-gnu@1.27.0:
     resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-x64-gnu@1.32.0:
     resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [glibc]
 
   lightningcss-linux-x64-musl@1.27.0:
     resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-linux-x64-musl@1.32.0:
     resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [linux]
-    libc: [musl]
 
   lightningcss-win32-arm64-msvc@1.27.0:
     resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==}

+ 6 - 4
src/app/(tabs)/analytics.tsx

@@ -4,7 +4,7 @@ import { useSWC } from '@/utils/cache';
 import { ActivityIndicator } from '@ant-design/react-native';
 import { Ionicons } from '@expo/vector-icons';
 import { useFocusEffect } from 'expo-router';
-import React from 'react';
+import React, { useCallback } from 'react';
 import { Pressable, ScrollView, Text, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 import { UploadComponent } from '../credit/upload';
@@ -30,9 +30,11 @@ export default function AnalyticsScreen() {
   })
 
 
-  useFocusEffect(() => {
-    load();
-  });
+  useFocusEffect(
+    useCallback(() => {
+      load();
+    }, [])
+  );
 
   return (
     <SafeAreaView className="flex-1 bg-surface" edges={['top']}>

+ 265 - 117
src/app/(tabs)/customer.tsx

@@ -1,115 +1,102 @@
 import { StatusBadge } from '@/components/ui/status-badge';
+import api, { ListResponse } from '@/utils/api';
+import { getApiCache } from '@/utils/storage';
+import { ActivityIndicator, Toast } from '@ant-design/react-native';
 import { Ionicons } from '@expo/vector-icons';
-import React, { useState } from 'react';
+import { useFocusEffect } from 'expo-router';
+import React, { useCallback, useRef, useState } from 'react';
 import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 
-type CustomerStatus = '全部' | '待上传' | '解析中' | '已分析' | '已匹配' | '审核中';
+type CustomerLoanStatus = 'matched' | 'unmatch' | 'pending' | 'completed' | undefined;
+const CustomerLoanStatusText: Record<NonNullable<CustomerLoanStatus>, string> = {
+  matched: '已匹配',
+  unmatch: '未匹配',
+  pending: '匹配中',
+  completed: '已完成'
+}
 
 type Customer = {
   id: string;
   name: string;
-  phone: string;
-  loanType: string;
-  status: Exclude<CustomerStatus, '全部'>;
+  mobile: string;
+  loan_status: CustomerLoanStatus;
+  note: string,
   score?: string;
-  note: string;
-  updatedAt: string;
+  updatetime: string;
 };
 
-const FILTERS: CustomerStatus[] = ['全部', '待上传', '解析中', '已分析', '已匹配', '审核中'];
 
-const CUSTOMERS: Customer[] = [
-  {
-    id: '1',
-    name: '张德发',
-    phone: '138****8888',
-    loanType: '经营贷',
-    status: '审核中',
-    score: 'B+',
-    note: '申请材料已补齐,等待风控初审',
-    updatedAt: '今天 10:42',
-  },
-  {
-    id: '2',
-    name: '李美华',
-    phone: '139****6666',
-    loanType: '消费贷',
-    status: '已匹配',
-    score: 'A',
-    note: '已匹配 3 个可推荐产品,待沟通方案',
-    updatedAt: '今天 09:18',
-  },
-  {
-    id: '3',
-    name: '王大锤',
-    phone: '137****3333',
-    loanType: '房抵贷',
-    status: '已分析',
-    score: 'B',
-    note: '征信报告已生成,建议先推进抵押类产品',
-    updatedAt: '昨天 16:20',
-  },
-  {
-    id: '4',
-    name: '赵丽',
-    phone: '136****1111',
-    loanType: '消费贷',
-    status: '待上传',
-    note: '客户资料已建档,待上传征信文件',
-    updatedAt: '2天前',
-  },
-  {
-    id: '5',
-    name: '钱进',
-    phone: '135****9999',
-    loanType: '经营贷',
-    status: '解析中',
-    note: '系统正在解析征信数据,预计 30-90 秒完成',
-    updatedAt: '刚刚',
-  },
-];
 
-function getBadgeVariant(status: Customer['status']) {
-  switch (status) {
-    case '待上传':
-      return 'error' as const;
-    case '解析中':
-    case '审核中':
-      return 'secondary' as const;
-    case '已分析':
-      return 'primary' as const;
-    case '已匹配':
-      return 'tertiary' as const;
-    default:
-      return 'secondary' as const;
-  }
-}
+export default function CustomerScreens() {
 
-function getPrimaryAction(status: Customer['status']) {
-  switch (status) {
-    case '待上传':
-      return '上传征信';
-    case '解析中':
-      return '查看进度';
-    default:
-      return '查看报告';
-  }
-}
 
-export default function CustomerScreens() {
-  const [search, setSearch] = useState('');
-  const [activeFilter, setActiveFilter] = useState<CustomerStatus>('全部');
+  const [searchKey, setSearchKey] = useState('');
+  const [list, setList] = useState<Customer[]>([]);
+  const listRef = useRef<Customer[]>([]);
+  const startRef = useRef<number>(0);
+  const loanStatusRef = useRef<CustomerLoanStatus>(undefined);
+  const hasMoreRef = useRef(true);
+  const [loading, setLoading] = useState(true);
+  const load = useCallback(async (start: number, loanStatus?: CustomerLoanStatus) => {
+    loanStatusRef.current = loanStatus;
+    startRef.current = start;
+    setLoading(true);
+    if (start === 0 && !loanStatus) {
+      const cache = getApiCache().getObject<ListResponse<Customer>>("customer_first");
+      if (cache) {
+        setLoading(false);
+        listRef.current = cache.list;
+        startRef.current += cache.list.length;
+        setList(cache.list);
+        hasMoreRef.current = list.length > 14;
+        return;
+      }
+    }
+    try {
+      // @ts-ignore
+      const res = await api.post<ListResponse<Customer>>('customer/list', {
+        start,
+        size: 15,
+        loanStatus
+      });
+      if (!res) {
+        throw "err";
+      }
+      const { list } = res;
+      if (list.length == 0) {
+        Toast.offline("没有更多数据可加载");
+        return;
+      }
+      if (start === 0 && !loanStatus) {
+        getApiCache().setObject('customer_first', res, 60);
+      }
+      if (loanStatusRef.current != loanStatus) {
+        return;
+      }
+      listRef.current = start == 0 ? list : listRef.current.concat(...list);
+      hasMoreRef.current = list.length > 14;
+      startRef.current += list.length;
+      setList(() => [...listRef.current]);
+    } catch (e) {
+      Toast.fail("加载列表失败!");
+    }
+    finally {
+      setLoading(false);
+    }
+  }, []);
 
-  const filteredCustomers = CUSTOMERS.filter((item) => {
-    const matchSearch =
-      search.trim().length === 0 ||
-      item.name.includes(search) ||
-      item.phone.includes(search);
-    const matchFilter = activeFilter === '全部' || item.status === activeFilter;
-    return matchSearch && matchFilter;
-  });
+  useFocusEffect(
+    useCallback(() => {
+      load(0);
+    }, []));
 
+  const loadMore = useCallback(() => {
+    if (!hasMoreRef.current) {
+      return;
+    }
+    load(startRef.current);
+  }, []);
   return (
     <SafeAreaView className="flex-1 bg-surface" edges={['top']}>
       <ScrollView
@@ -128,17 +115,18 @@ export default function CustomerScreens() {
         <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="#737686" />
           <TextInput
-            value={search}
-            onChangeText={setSearch}
+            value={searchKey}
+            onChangeText={setSearchKey}
             placeholder="搜索客户姓名 / 手机号"
             placeholderTextColor="#9ca3af"
             className="ml-3 flex-1 p-0 text-base text-on-surface"
           />
-          {search.length > 0 ? (
-            <Pressable hitSlop={8} onPress={() => setSearch('')}>
+          {!loading && searchKey.length > 0 ? (
+            <Pressable hitSlop={8} onPress={() => setSearchKey('')}>
               <Ionicons name="close-circle" size={20} color="#c3c6d7" />
             </Pressable>
           ) : null}
+
         </View>
 
         <ScrollView
@@ -147,25 +135,23 @@ export default function CustomerScreens() {
           contentContainerClassName="gap-2 pb-1"
           className="mb-5"
         >
-          {FILTERS.map((filter) => {
-            const active = activeFilter === filter;
+          {Object.entries(CustomerLoanStatusText).map(([key, item]) => {
+            const active = false;
             return (
               <Pressable
-                key={filter}
-                onPress={() => setActiveFilter(filter)}
-                className={`rounded-full px-4 py-2 ${
-                  active ? 'bg-primary-container' : 'bg-surface-container-lowest'
-                }`}
+                key={key}
+                onPress={() => { }}
+                className={`rounded-full px-4 py-2 ${active ? 'bg-primary-container' : 'bg-surface-container-lowest'
+                  }`}
                 style={({ pressed }) => ({
                   opacity: pressed ? 0.84 : 1,
                 })}
               >
                 <Text
-                  className={`text-sm font-bold ${
-                    active ? 'text-on-primary' : 'text-on-surface-variant'
-                  }`}
+                  className={`text-sm font-bold ${active ? 'text-on-primary' : 'text-on-surface-variant'
+                    }`}
                 >
-                  {filter}
+                  {item}
                 </Text>
               </Pressable>
             );
@@ -173,13 +159,172 @@ export default function CustomerScreens() {
         </ScrollView>
 
         <View className="gap-3">
-          {filteredCustomers.length === 0 ? (
+          {true && startRef.current == 0 && <View className='flex-row justify-center items-center mt-2'>
+            <ActivityIndicator /><Text className='ml-2 pb-4'>加载中</Text>
+          </View>}
+          {list.length === 0 ? (
+            <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="#c3c6d7" />
+              <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
+            </View>
+          ) : (
+            list.map((item) => (
+              <Pressable
+                key={item.id}
+                className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4"
+                style={({ pressed }) => ({
+                  opacity: pressed ? 0.93 : 1,
+                  transform: [{ scale: pressed ? 0.995 : 1 }],
+                })}
+              >
+                <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={14} color="#737686" />
+                    <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={14} color="#737686" />
+                      <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"
+                    style={({ pressed }) => ({
+                      opacity: pressed ? 0.88 : 1,
+                    })}
+                  >
+                    <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"
+                    style={({ pressed }) => ({
+                      opacity: pressed ? 0.88 : 1,
+                    })}
+                  >
+                    <Text className="text-sm font-semibold text-on-surface">编辑资料</Text>
+                  </Pressable>
+                </View>
+              </Pressable>
+            ))
+          )}
+        </View>
+        <View className="gap-3">
+          {list.length === 0 ? (
+            <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="#c3c6d7" />
+              <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
+            </View>
+          ) : (
+            list.map((item) => (
+              <Pressable
+                key={item.id}
+                className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4"
+                style={({ pressed }) => ({
+                  opacity: pressed ? 0.93 : 1,
+                  transform: [{ scale: pressed ? 0.995 : 1 }],
+                })}
+              >
+                <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={14} color="#737686" />
+                    <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={14} color="#737686" />
+                      <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"
+                    style={({ pressed }) => ({
+                      opacity: pressed ? 0.88 : 1,
+                    })}
+                  >
+                    <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"
+                    style={({ pressed }) => ({
+                      opacity: pressed ? 0.88 : 1,
+                    })}
+                  >
+                    <Text className="text-sm font-semibold text-on-surface">编辑资料</Text>
+                  </Pressable>
+                </View>
+              </Pressable>
+            ))
+          )}
+        </View>
+        <View className="gap-3">
+          {list.length === 0 ? (
             <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="#c3c6d7" />
               <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
             </View>
           ) : (
-            filteredCustomers.map((item) => (
+            list.map((item) => (
               <Pressable
                 key={item.id}
                 className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4"
@@ -196,13 +341,13 @@ export default function CustomerScreens() {
                     <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.phone}
+                        {item.mobile}
                       </Text>
                     </View>
                   </View>
                   <StatusBadge
-                    text={item.status}
-                    variant={getBadgeVariant(item.status)}
+                    text={item.loan_status || ''}
+                    variant={'secondary'}
                   />
                 </View>
 
@@ -213,7 +358,7 @@ export default function CustomerScreens() {
                 <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={14} color="#737686" />
-                    <Text className="text-xs text-on-surface-variant">{item.loanType}</Text>
+                    <Text className="text-xs text-on-surface-variant">{item.loan_status}</Text>
                   </View>
                   {item.score ? (
                     <View className="flex-row items-center gap-1">
@@ -223,7 +368,7 @@ export default function CustomerScreens() {
                       </Text>
                     </View>
                   ) : null}
-                  <Text className="ml-auto text-xs text-outline">{item.updatedAt}</Text>
+                  <Text className="ml-auto text-xs text-outline">{item.updatetime}</Text>
                 </View>
 
                 <View className="flex-row gap-3">
@@ -234,7 +379,7 @@ export default function CustomerScreens() {
                     })}
                   >
                     <Text className="text-sm font-bold text-on-primary">
-                      {getPrimaryAction(item.status)}
+                      {item.loan_status}
                     </Text>
                   </Pressable>
                   <Pressable
@@ -250,6 +395,9 @@ export default function CustomerScreens() {
             ))
           )}
         </View>
+        {loading && startRef.current > 0 && <View className='flex-row justify-center items-center mt-2'>
+          <ActivityIndicator /><Text className='ml-2 pb-4'>加载中</Text>
+        </View>}
       </ScrollView>
 
       <View className="absolute bottom-7 right-5">

+ 13 - 6
src/app/(tabs)/index.tsx

@@ -5,7 +5,8 @@ import api from '@/utils/api';
 import { useSWC } from '@/utils/cache';
 import { Ionicons } from '@expo/vector-icons';
 
-import { useState } from 'react';
+import { useFocusEffect } from 'expo-router';
+import { useCallback, useState } from 'react';
 import { Pressable, ScrollView, Text, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 import { UploadScreen } from '../credit/upload';
@@ -90,17 +91,23 @@ function getGreeting() {
 }
 
 export default function HomeScreen() {
-  const { data: summary } = useSWC<{
+  const { data: summary, load } = useSWC<{
     pending: number;
     matched: number;
     completed: number;
   }>('home/summary', async () => {
     return api.get("share/summary");
   }, {
-    cacheTimeout: 300,
-    cacheOnly: false
-  }
-  );
+    cacheTimeout: 60,
+    cacheOnly: false,
+    autoStart: false
+  });
+
+  useFocusEffect(
+    useCallback(() => {
+      load();
+    }, []));
+
 
   const [selectCredit, setSelectCredit] = useState(false);
   return (

+ 3 - 3
src/app/credit/upload.tsx

@@ -131,7 +131,7 @@ const pickDoc = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset
 };
 
 
-export function UploadScreen({ visible, onClose }: { visible: boolean, onClose: () => void }) {
+export function UploadScreen({ visible, onClose }: { visible: boolean, onClose: (isCancel: boolean) => void }) {
 
 
     useEffect(() => {
@@ -199,7 +199,7 @@ export function UploadScreen({ visible, onClose }: { visible: boolean, onClose:
                             <Text className="mt-4 ml-4 text-2xl">AI 正在分析</Text>
                         </View>
                         <Text className="mt-4 ml-4 text-2xl">
-                            <Link asChild href='/(tabs)/analytics' onPress={onClose}>
+                            <Link asChild href='/(tabs)/analytics' onPress={() => onClose(state != 2)}>
                                 <Text className="text-primary">分析页</Text>
                             </Link>
                             查看状态和结果</Text>
@@ -207,7 +207,7 @@ export function UploadScreen({ visible, onClose }: { visible: boolean, onClose:
                 )
             }
             <View className="h-8" />
-            {state != 1 && <Pressable hitSlop={8} className="absolute top-0 right-4" onPress={() => { onClose(); }}>
+            {state != 1 && <Pressable hitSlop={8} className="absolute top-0 right-4" onPress={() => { onClose(state !== 2); }}>
                 <Icon name="close" size={32} />
             </Pressable>}
         </Modal >

+ 231 - 125
src/app/customer/add.tsx

@@ -1,8 +1,9 @@
 import UIButton from "@/components/ui/UIButton";
 import { Colors } from "@/constants/theme";
-import { DatePicker, Input, Modal, Picker, Toast } from "@ant-design/react-native";
+import api from "@/utils/api";
+import { DatePicker, Input, Modal, Radio, Toast } from "@ant-design/react-native";
 import { Ionicons } from "@expo/vector-icons";
-import { usePreventRemove } from "@react-navigation/native";
+import { usePreventRemove, useRoute } from "@react-navigation/native";
 import { NavigationAction } from "@react-navigation/routers";
 import { Link, Stack, useNavigation } from "expo-router";
 import React, { useCallback, useRef, useState } from "react";
@@ -13,25 +14,27 @@ import {
   ScrollView,
   StyleSheet,
   Text,
+  TextInput,
   View,
 } from "react-native";
 import { useSafeAreaInsets } from "react-native-safe-area-context";
-import UploadScreen from "../credit/upload";
+import { UploadScreen } from "../credit/upload";
 
 type CustomerForm = {
   name: string;
   mobile: string;
   gender: string;
-  birthDate?: Date;
-  idCard: string;
-  maritalStatus: string;
-  hometown: string;
-  residence: string;
+  birthday?: Date;
+  idcard: string;
+  nation: string;
+  registered: string;
+  residential: string;
+  photo: string;
   occupation: string;
-  others: string;
+  other: string;
 };
 
-type FieldKey = "name" | "mobile" | "idCard";
+type FieldKey = "name" | "mobile" | "idcard";
 type FieldErrors = Partial<Record<FieldKey, string>>;
 type BirthDateSource = "unset" | "manual" | "id-card";
 
@@ -41,20 +44,46 @@ type Option = {
 };
 
 const GENDER_OPTIONS: Option[] = [
-  { label: "男", value: "male" },
-  { label: "女", value: "female" },
-];
-
-const MARITAL_STATUS_OPTIONS: Option[] = [
-  { label: "未婚", value: "single" },
-  { label: "已婚", value: "married" },
-  { label: "离异", value: "divorced" },
-  { label: "丧偶", value: "widowed" },
+  { label: "男", value: "1" },
+  { label: "女", value: "2" },
+  { label: "其它", value: "3" }
 ];
 
 const MOBILE_PATTERN = /^1\d{10}$/;
 const IDCARD_PATTERN = /^(?:\d{15}|\d{17}[\dX])$/;
 
+const EMPTY_CUSTOMER_FORM: CustomerForm = {
+  name: "",
+  mobile: "",
+  gender: "",
+  birthday: undefined,
+  idcard: "",
+  nation: "",
+  registered: "",
+  residential: "",
+  photo: "",
+  occupation: "",
+  other: "",
+};
+
+type CustomerExtPayload = {
+  nation: string;
+  registered: string | null;
+  residential: string | null;
+  photo: string | null;
+  occupation: string | null;
+  other: string | null;
+};
+
+type CustomerPayload = {
+  name: string;
+  mobile: string;
+  gender: number;
+  birthday: string | null;
+  idcard: string;
+  ext: CustomerExtPayload;
+};
+
 function sanitizePhone(value: string) {
   return value.replace(/\D/g, "").slice(0, 11);
 }
@@ -111,22 +140,23 @@ function inferBirthDateFromIdCard(idCard: string) {
   return new Date(year, month - 1, day);
 }
 
-function getOptionLabel(options: Option[], value: string) {
-  return options.find((item) => item.value === value)?.label ?? "";
+function nullableText(value: string) {
+  const trimmed = value.trim();
+  return trimmed || null;
 }
 
 function validateForm(form: CustomerForm): FieldErrors {
   const nextErrors: FieldErrors = {};
   const mobile = form.mobile.trim();
-  const idCard = form.idCard.trim();
+  const idcard = form.idcard.trim();
 
   if (!form.name.trim()) {
     nextErrors.name = "请输入客户姓名";
   }
 
-  if (!mobile && !idCard) {
+  if (!mobile && !idcard) {
     nextErrors.mobile = "手机号和身份证号至少填写一项";
-    nextErrors.idCard = "手机号和身份证号至少填写一项";
+    nextErrors.idcard = "手机号和身份证号至少填写一项";
     return nextErrors;
   }
 
@@ -134,13 +164,31 @@ function validateForm(form: CustomerForm): FieldErrors {
     nextErrors.mobile = "请输入 11 位手机号";
   }
 
-  if (idCard && !IDCARD_PATTERN.test(idCard)) {
-    nextErrors.idCard = "请输入正确的身份证号";
+  if (idcard && !IDCARD_PATTERN.test(idcard)) {
+    nextErrors.idcard = "请输入正确的身份证号";
   }
 
   return nextErrors;
 }
 
+function buildCustomerPayload(form: CustomerForm): CustomerPayload {
+  return {
+    name: form.name.trim(),
+    mobile: form.mobile.trim(),
+    gender: Number(form.gender) || 0,
+    birthday: form.birthday ? formatDate(form.birthday) : null,
+    idcard: form.idcard.trim(),
+    ext: {
+      nation: form.nation.trim(),
+      registered: nullableText(form.registered),
+      residential: nullableText(form.residential),
+      photo: nullableText(form.photo),
+      occupation: nullableText(form.occupation),
+      other: nullableText(form.other),
+    },
+  };
+}
+
 function FieldLabel({
   label,
   required = false,
@@ -195,7 +243,6 @@ function PickerField({
 }) {
   const hasValue = Boolean(value);
 
-  const [selectCredit, setSelectCredit] = useState(false);
   return (
     <View className="mb-4">
       <FieldLabel label={label} required={required} />
@@ -230,33 +277,27 @@ function PickerField({
 export default function AddCustomerScreen() {
   const insets = useSafeAreaInsets();
   const navigation = useNavigation();
-  const [form, setForm] = useState<CustomerForm>({
-    name: "",
-    mobile: "",
-    gender: "",
-    birthDate: undefined,
-    idCard: "",
-    maritalStatus: "",
-    hometown: "",
-    residence: "",
-    occupation: "",
-    others: "",
-  });
+  const [form, setForm] = useState<CustomerForm>(EMPTY_CUSTOMER_FORM);
   const [errors, setErrors] = useState<FieldErrors>({});
   const [birthDateSource, setBirthDateSource] = useState<BirthDateSource>("unset");
+  const [saving, setSaving] = useState(false);
   const leaveConfirmVisibleRef = useRef(false);
+  const savedLeaveRef = useRef(false);
+  const route = useRoute();
+  const scrollViewRef = useRef<ScrollView>(null!);
 
   const hasDraft = Boolean(
     form.name.trim() ||
     form.mobile.trim() ||
     form.gender ||
-    form.birthDate ||
-    form.idCard.trim() ||
-    form.maritalStatus ||
-    form.hometown.trim() ||
-    form.residence.trim() ||
+    form.birthday ||
+    form.idcard.trim() ||
+    form.nation.trim() ||
+    form.registered.trim() ||
+    form.residential.trim() ||
+    form.photo.trim() ||
     form.occupation.trim() ||
-    form.others.trim()
+    form.other.trim()
   );
 
   const openLeaveConfirm = useCallback(
@@ -296,6 +337,11 @@ export default function AddCustomerScreen() {
   );
 
   usePreventRemove(hasDraft, ({ data }) => {
+    if (savedLeaveRef.current) {
+      navigation.dispatch(data.action);
+      return;
+    }
+
     openLeaveConfirm(data.action);
   });
 
@@ -316,23 +362,22 @@ export default function AddCustomerScreen() {
 
   const handleMobileChange = (value: string) => {
     setForm((prev) => ({ ...prev, mobile: sanitizePhone(value) }));
-    clearErrors(["mobile", "idCard"]);
+    clearErrors(["mobile", "idcard"]);
   };
 
   const handleIdCardChange = (value: string) => {
     const nextIdCard = sanitizeIdCard(value);
     const inferredBirthDate = inferBirthDateFromIdCard(nextIdCard);
-    const canAutofillBirthDate = birthDateSource !== "manual" || !form.birthDate;
-
+    const canAutofillBirthDate = birthDateSource !== "manual" || !form.birthday;
     setForm((prev) => ({
       ...prev,
-      idCard: nextIdCard,
-      birthDate:
+      idcard: nextIdCard,
+      birthday:
         inferredBirthDate && canAutofillBirthDate
           ? inferredBirthDate
           : !inferredBirthDate && birthDateSource === "id-card"
             ? undefined
-            : prev.birthDate,
+            : prev.birthday,
     }));
 
     if (inferredBirthDate && canAutofillBirthDate) {
@@ -341,20 +386,71 @@ export default function AddCustomerScreen() {
       setBirthDateSource("unset");
     }
 
-    clearErrors(["mobile", "idCard"]);
+    clearErrors(["mobile", "idcard"]);
   };
+  const savedRef = useRef<boolean>(false);
+
+
+  const onSelectCredis = (isCancel: boolean) => {
+    setSelectCredit(false);
+    // if (isCancel) {
+    //   return;
+    // }
+    if (savedRef.current || !isCancel) {
+      navigation.goBack();
+      return;
+    }
+  }
+
+  const handleSubmit = async () => {
+    if (saving) {
+      return;
+    }
 
-  const handleSubmit = () => {
     const nextErrors = validateForm(form);
     setErrors(nextErrors);
 
     const firstError = Object.values(nextErrors)[0];
     if (firstError) {
       Toast.fail(firstError);
+      scrollViewRef.current?.scrollTo({ y: 160, animated: true });
       return;
     }
 
-    Toast.success("表单校验通过,后续可直接接入保存接口");
+    setSaving(true);
+    const toastKey = Toast.loading("正在保存客户...");
+
+    try {
+      const customerId = await api.post("/customer/add", buildCustomerPayload(form));
+      Toast.success("客户已保存");
+      savedLeaveRef.current = true;
+      // setForm(EMPTY_CUSTOMER_FORM);
+      // setBirthDateSource("unset");
+      // setErrors({});
+      // @ts-ignore
+
+      if (route.params?.onGoBack) {
+        // @ts-ignore
+        route.params.onGoBack({ customerId });
+        navigation.goBack();
+      } else {
+        Modal.alert("客户添加完成", "是否立即上传征信信息", [{
+          text: '否',
+        }, {
+          'text': '立即上传',
+          onPress: () => {
+            setSelectCredit(true);
+          }
+        }])
+      }
+
+    } catch (error) {
+      console.error("保存客户失败:", error);
+      Toast.fail(error instanceof Error ? error.message : "保存客户失败,请稍后重试");
+    } finally {
+      setSaving(false);
+      Toast.remove(toastKey);
+    }
   };
 
 
@@ -365,6 +461,8 @@ export default function AddCustomerScreen() {
 
 
   const [selectCredit, setSelectCredit] = useState(false);
+
+
   return (
     <View className="flex-1 bg-surface">
       <Stack.Screen options={{ title: "添加客户" }} />
@@ -373,6 +471,7 @@ export default function AddCustomerScreen() {
         behavior={Platform.OS === "ios" ? "padding" : undefined}
       >
         <ScrollView
+          ref={scrollViewRef}
           className="flex-1"
           contentContainerClassName="px-5 pb-10"
           contentContainerStyle={{
@@ -420,45 +519,46 @@ export default function AddCustomerScreen() {
             <FieldMessage error={errors.name} />
           </View>
 
-          <Picker
-            data={GENDER_OPTIONS}
-            cols={1}
-            value={form.gender ? [form.gender] : []}
-            onOk={(values) => {
-              const selected = values[0];
-              setForm((prev) => ({
-                ...prev,
-                gender: typeof selected === "string" ? selected : String(selected ?? ""),
-              }));
-            }}
-            format={(labels) => labels[0] ?? ""}
-          >
-            {({ disabled, toggle }) => (
-              <PickerField
-                label="性别"
-                value={getOptionLabel(GENDER_OPTIONS, form.gender)}
-                placeholder="请选择性别"
-                disabled={disabled}
-                onPress={toggle}
-              />
-            )}
-          </Picker>
+          <View className="mb-4">
+            <FieldLabel label="性别" />
+            <Radio.Group
+              value={form.gender}
+              onChange={(event) => {
+                const selected = event.target.value;
+                if (typeof selected !== "string" && typeof selected !== "number") {
+                  return;
+                }
+
+                setForm((prev) => ({
+                  ...prev,
+                  gender: String(selected),
+                }));
+              }}
+              style={styles.radioGroup}
+            >
+              {GENDER_OPTIONS.map((option) => (
+                <Radio key={option.value} value={option.value}>
+                  {option.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </View>
 
           <DatePicker
             precision="day"
-            value={form.birthDate}
+            value={form.birthday}
             minDate={new Date(1950, 0, 1)}
             maxDate={new Date()}
             format={formatDate}
             onOk={(value) => {
-              setForm((prev) => ({ ...prev, birthDate: value }));
+              setForm((prev) => ({ ...prev, birthday: value }));
               setBirthDateSource("manual");
             }}
           >
             {({ disabled, toggle }) => (
               <PickerField
                 label="出生日期"
-                value={formatDate(form.birthDate)}
+                value={formatDate(form.birthday)}
                 placeholder="请选择出生日期"
                 helper={birthDateHelper}
                 disabled={disabled}
@@ -469,20 +569,20 @@ export default function AddCustomerScreen() {
 
           <View className="mb-4">
             <FieldLabel label="手机号" />
-            <Input
+            <TextInput
               value={form.mobile}
               onChangeText={handleMobileChange}
               placeholder="请输入 11 位手机号"
-              allowClear
               keyboardType="phone-pad"
+              placeholderTextColor="#9ca3af"
               textContentType="telephoneNumber"
+              underlineColorAndroid="transparent"
               maxLength={11}
-              status={errors.mobile ? "error" : undefined}
               style={[
                 styles.inputContainer,
+                styles.inputText,
                 errors.mobile ? styles.inputContainerError : undefined,
               ]}
-              inputStyle={styles.inputText}
             />
             <FieldMessage
               error={errors.mobile}
@@ -492,57 +592,45 @@ export default function AddCustomerScreen() {
 
           <View className="mb-4">
             <FieldLabel label="身份证号" />
-            <Input
-              value={form.idCard}
+            <TextInput
+              value={form.idcard}
               onChangeText={handleIdCardChange}
               placeholder="请输入身份证号"
-              allowClear
               autoCapitalize="characters"
+              autoCorrect={false}
+              placeholderTextColor="#9ca3af"
+              underlineColorAndroid="transparent"
               maxLength={18}
-              status={errors.idCard ? "error" : undefined}
               style={[
                 styles.inputContainer,
-                errors.idCard ? styles.inputContainerError : undefined,
+                styles.inputText,
+                errors.idcard ? styles.inputContainerError : undefined,
               ]}
-              inputStyle={styles.inputText}
             />
             <FieldMessage
-              error={errors.idCard}
-              helper={errors.idCard ? undefined : "填写 18 位身份证后会自动识别出生日期"}
+              error={errors.idcard}
+              helper={errors.idcard ? undefined : "填写 18 位身份证后会自动识别出生日期"}
             />
           </View>
 
-
-          <Picker
-            data={MARITAL_STATUS_OPTIONS}
-            cols={1}
-            value={form.maritalStatus ? [form.maritalStatus] : []}
-            onOk={(values) => {
-              const selected = values[0];
-              setForm((prev) => ({
-                ...prev,
-                maritalStatus:
-                  typeof selected === "string" ? selected : String(selected ?? ""),
-              }));
-            }}
-            format={(labels) => labels[0] ?? ""}
-          >
-            {({ disabled, toggle }) => (
-              <PickerField
-                label="婚姻状态"
-                value={getOptionLabel(MARITAL_STATUS_OPTIONS, form.maritalStatus)}
-                placeholder="请选择婚姻状态"
-                disabled={disabled}
-                onPress={toggle}
-              />
-            )}
-          </Picker>
+          <View className="mb-4">
+            <FieldLabel label="民族" />
+            <Input
+              value={form.nation}
+              onChangeText={(value) => setForm((prev) => ({ ...prev, nation: value }))}
+              placeholder="请输入民族"
+              allowClear
+              maxLength={16}
+              style={styles.inputContainer}
+              inputStyle={styles.inputText}
+            />
+          </View>
 
           <View className="mb-4">
             <FieldLabel label="户籍所在地" />
             <Input
-              value={form.hometown}
-              onChangeText={(value) => setForm((prev) => ({ ...prev, hometown: value }))}
+              value={form.registered}
+              onChangeText={(value) => setForm((prev) => ({ ...prev, registered: value }))}
               placeholder="请输入户籍所在地"
               allowClear
               style={styles.inputContainer}
@@ -553,8 +641,8 @@ export default function AddCustomerScreen() {
           <View className="mb-4">
             <FieldLabel label="现居住地" />
             <Input
-              value={form.residence}
-              onChangeText={(value) => setForm((prev) => ({ ...prev, residence: value }))}
+              value={form.residential}
+              onChangeText={(value) => setForm((prev) => ({ ...prev, residential: value }))}
               placeholder="请输入现居住地"
               allowClear
               style={styles.inputContainer}
@@ -562,6 +650,18 @@ export default function AddCustomerScreen() {
             />
           </View>
 
+          <View className="mb-4">
+            <FieldLabel label="照片" />
+            <Input
+              value={form.photo}
+              onChangeText={(value) => setForm((prev) => ({ ...prev, photo: value }))}
+              placeholder="请输入照片地址"
+              allowClear
+              style={styles.inputContainer}
+              inputStyle={styles.inputText}
+            />
+          </View>
+
           <View className="mb-4">
             <FieldLabel label="职业" />
             <Input
@@ -577,8 +677,8 @@ export default function AddCustomerScreen() {
           <View>
             <FieldLabel label="其它" />
             <Input.TextArea
-              value={form.others}
-              onChangeText={(value) => setForm((prev) => ({ ...prev, others: value }))}
+              value={form.other}
+              onChangeText={(value) => setForm((prev) => ({ ...prev, other: value }))}
               placeholder="补充备注、渠道来源、客户标签等"
               autoSize={{ minRows: 4, maxRows: 6 }}
               maxLength={200}
@@ -589,12 +689,12 @@ export default function AddCustomerScreen() {
             />
           </View>
 
-          <UIButton type="primary" icon="save" onPress={handleSubmit}>
+          <UIButton type="primary" icon="save" onPress={handleSubmit} disabled={saving} loading={saving}>
             保存客户
           </UIButton>
         </ScrollView>
       </KeyboardAvoidingView>
-      <UploadScreen visible={selectCredit} onClose={() => setSelectCredit(false)} />
+      <UploadScreen visible={selectCredit} onClose={onSelectCredis} />
     </View>
   );
 }
@@ -617,6 +717,12 @@ const styles = StyleSheet.create({
     fontSize: 16,
     paddingVertical: Platform.OS === "web" ? 12 : 10,
   },
+  radioGroup: {
+    flexDirection: "row",
+    gap: 24,
+    minHeight: 44,
+    alignItems: "center",
+  },
   textAreaContainer: {
     borderRadius: 16,
     borderWidth: 1,

+ 15 - 9
src/components/ui/UIButton.tsx

@@ -47,11 +47,17 @@ function ButtonTextChild({ type, disabled, textClassName, children, loading }: U
 
 export default function UIButton({ onPress, href, type, icon, className, style, disabled, children, title, textClassName, loading }: UIButtonProps) {
     const [ding, setDing] = useState(loading);
+    const isDisabled = Boolean(disabled || ding);
+
     useEffect(() => {
         setDing(loading);
     }, [loading])
     children = children || title;
     const handlePress = useCallback(() => {
+        if (isDisabled) {
+            return;
+        }
+
         const res = onPress?.() as Promise<any> || undefined;
         if (isPromise(res)) {
             setDing(true);
@@ -59,7 +65,7 @@ export default function UIButton({ onPress, href, type, icon, className, style,
                 setDing(false);
             })
         }
-    }, []);
+    }, [isDisabled, onPress]);
     const inner = useMemo(() => <Pressable
         className={clsx('flex-row justify-center items-center rounded-xl py-1.5 border-2 opacity-100 active:opacity-75 active:scale-95',
             className, {
@@ -67,16 +73,16 @@ export default function UIButton({ onPress, href, type, icon, className, style,
             'bg-surface border-primary/25': !type,
             'bg-surface border-on-surface/25': type === 'second',
             'bg-transparent border-transparent': type === 'link',
-            'bg-primary-fixed-dim border-primary-fixed-dim': disabled && type === 'primary',
-            'bg-gray-300 border-gray-300': disabled && !type,
-            'bg-bg-gray-400 border-gray-200': disabled && type === 'second'
+            'bg-primary-fixed-dim border-primary-fixed-dim': isDisabled && type === 'primary',
+            'bg-gray-300 border-gray-300': isDisabled && !type,
+            'bg-bg-gray-400 border-gray-200': isDisabled && type === 'second'
         })}
-        disabled={disabled}
+        disabled={isDisabled}
         onPress={handlePress}
         style={style}>
-        {typeof icon === 'string' ? <Icon name={icon as IconNames} size={20} style={{ marginRight: 4, color: disabled ? Colors['on-surface']['variant'] : type === 'primary' ? '#fff' : type === 'second' ? Colors.secondary.DEFAULT : Colors.primary.DEFAULT }} /> : icon}
-        {typeof children == 'string' ? <ButtonTextChild type={type} disabled={disabled} loading={loading} textClassName={textClassName}>{children}</ButtonTextChild> : children}
-    </Pressable >, [children, className, disabled, icon, onPress, style, textClassName, type])
+        {typeof icon === 'string' ? <Icon name={icon as IconNames} size={20} style={{ marginRight: 4, color: isDisabled ? Colors['on-surface']['variant'] : type === 'primary' ? '#fff' : type === 'second' ? Colors.secondary.DEFAULT : Colors.primary.DEFAULT }} /> : icon}
+        {typeof children == 'string' ? <ButtonTextChild type={type} disabled={isDisabled} loading={ding} textClassName={textClassName}>{children}</ButtonTextChild> : children}
+    </Pressable >, [children, className, ding, handlePress, icon, isDisabled, style, textClassName, type])
     if (typeof href !== 'undefined') {
         return <Link href={href} asChild>
             {inner}
@@ -84,4 +90,4 @@ export default function UIButton({ onPress, href, type, icon, className, style,
     }
 
     return inner;
-}
+}

+ 1 - 1
src/utils/api.ts

@@ -73,7 +73,7 @@ interface ApiResponse<T> {
 export interface ListResponse<T = unknown> {
     start?: number;
     size?: string;
-    list?: T[];
+    list: T[];
 }
 
 const verString = (() => {

+ 9 - 14
src/utils/auth.tsx

@@ -12,7 +12,7 @@ export interface AccessToken {
 
 interface AuthContextType {
   authStatus: 'auth' | 'loading' | 'fail';
-  setToken: (token: AccessToken |undefined | null) => void;
+  setToken: (token: AccessToken | undefined | null) => void;
 
 }
 
@@ -23,35 +23,30 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   const [authStatus, setAuthStatus] = useState<'auth' | 'loading' | 'fail'>('loading');
 
 
-  const setToken = (nextToken: AccessToken |undefined | null) => {
+  const setToken = (nextToken: AccessToken | undefined | null) => {
     // tokenCache = nextToken;
 
     if (nextToken && nextToken.token && nextToken.expiresIn) {
       // if (!tokenCache.expiresAt) {
-        nextToken.expiresAt = nextToken.expiresIn + Date.now() / 1000;
+      nextToken.expiresAt = nextToken.expiresIn + Date.now() / 1000;
       // }
 
     } else {
       nextToken = null;
     }
     if (nextToken) {
-        setAuthStatus('auth');
-        getGlobalStorage().set("access_token", JSON.stringify(nextToken));
+      setAuthStatus('auth');
+      getGlobalStorage().setObject("access_token", nextToken, nextToken.expiresIn);
     } else {
-        setAuthStatus('fail');
-        getGlobalStorage().remove("access_token");
+      setAuthStatus('fail');
+      getGlobalStorage().remove("access_token");
     }
     setAccessToken(nextToken);
   };
 
   useEffect(() => {
-    try {
-      const token = JSON.parse(getGlobalStorage().getString("access_token")||"null");
-      setToken(token);
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    }catch(e) {
-      setToken(null);
-    }
+    const token = getGlobalStorage().getObject("access_token");
+    setToken(token);
   }, []);
 
 

+ 3 - 53
src/utils/cache.ts

@@ -1,6 +1,5 @@
 import { useCallback, useEffect, useRef, useState } from "react";
-import { Platform } from "react-native";
-import { getApiCache, getGlobalStorage } from "./storage";
+import { getApiCache } from "./storage";
 
 
 
@@ -18,26 +17,7 @@ export function useSWC<T, S extends any[] = any>(key: string, action: () => Prom
     const optionsRef = useRef(options || {});
     optionsRef.current = options || {};
     const keyRef = useRef(key);
-    const [data, setData] = useState<T | null | undefined>(() => {
-        const caches = getApiCache();
-        try {
-            const data = JSON.parse(caches.getString(keyRef.current) || "null") as T;
-            if (!data) {
-                return undefined;
-            }
-            let ttl = optionsRef.current.cacheTimeout || MAX_CACHE_TIME;
-            // @ts-ignore
-            let cacheTime = data.$__cacheTime;
-            if (Date.now() - cacheTime > ttl * 1000) {
-                throw "expired";
-            }
-            return data;
-        // eslint-disable-next-line @typescript-eslint/no-unused-vars
-        } catch (_) {
-            caches.remove(keyRef.current);
-            return undefined;
-        }
-    });
+    const [data, setData] = useState<T | null | undefined>(() => getApiCache().getObject<T>(keyRef.current));
     const [loading, setLoading] = useState<true | 'background' | false>(true);
     const [error, setError] = useState<any>(null);
 
@@ -56,8 +36,7 @@ export function useSWC<T, S extends any[] = any>(key: string, action: () => Prom
         try {
             const result = await actionRef.current();
             // @ts-ignore
-            result.$__cacheTime = Date.now();
-            result && getApiCache().set(key, JSON.stringify(result));
+            result && getApiCache().setObject(key, result, options.cacheTimeout || 86400 * 120);
             if (isMountedRef.current) {
                 options?.onLoad?.(result);
                 setData(result);
@@ -103,32 +82,3 @@ export function useSWC<T, S extends any[] = any>(key: string, action: () => Prom
 }
 
 export const MAX_CACHE_TIME = 99999999999999;
-
-if (Platform.OS !== 'web') {
-
-let lastClearTime = parseInt(typeof window == undefined ? "0" : getGlobalStorage().getString("last_clear_time") || "0");
-const clearTimeout = 86400 * 3 * 1000;
-// 开启一直 30 分钏的定时器,用于清理缓存
-setInterval(() => {
-   
-    let now = Date.now();
-    if (now - lastClearTime < clearTimeout) {
-        lastClearTime = now;
-        const globalStorage = getGlobalStorage();
-        const caches = getApiCache();
-
-        caches.getAllKeys().forEach(key => {
-            try {
-                let {$__cacheTime} = JSON.parse(caches.getString(key) || `{"$__cacheTime": ${MAX_CACHE_TIME}}`);
-                    if (now - $__cacheTime > clearTimeout) {
-                        caches.remove(key);
-                    }
-                // eslint-disable-next-line @typescript-eslint/no-unused-vars
-                } catch (_e) {
-                }
-            });
-
-        globalStorage.set("last_clear_time", now+"");
-    }
-}, 60 * 15 * 1000);
-}

+ 75 - 4
src/utils/storage.ts

@@ -3,6 +3,7 @@ import * as fs from 'expo-file-system/legacy';
 
 import { createMMKV, MMKV } from 'react-native-mmkv';
 
+const TTL_KEY = '$__$t_';
 function fixPath(path: string) {
     if (path.startsWith("file:///")) {
         return path.substring(7);
@@ -11,35 +12,105 @@ function fixPath(path: string) {
         return path.replace(/^file:\//, "/");
     }
 }
-let globalStorage: MMKV | null = null;
+
+type KVDB = MMKV & {
+    getObject: <T = any>(key: string) => T | undefined;
+    setObject: (key: string, value: any, tt: number)=> void;
+}
+
+function setObject(that: MMKV) {
+    return (key: string, value: any, ttl: number) => {
+        let obj = {
+            [TTL_KEY]: Date.now() + ttl * 1000,
+            v: value
+        };
+        that.set(key, JSON.stringify(obj));
+    }
+}
+
+
+function getObject(that: MMKV) {
+    return <T>(key: string) =>  {
+        try {
+            const obj = JSON.parse(that.getString(key)!) as {
+                [TTL_KEY]: number;
+                v: T
+            };
+            if (Date.now() - obj[TTL_KEY] > -1) {
+                that.remove(key);
+                return undefined;
+            }
+            return obj.v;
+        } catch(e) {
+            // that.remove(key);
+        }
+       
+    }
+}
+
+let globalStorage: KVDB = null!;
 export function getGlobalStorage() {
     if (!globalStorage) {
+        // @ts-ignore
         globalStorage = createMMKV({
             id: `global`,
             path: fixPath(fs.cacheDirectory??''),
         });
+        globalStorage.setObject = setObject(globalStorage);
+        globalStorage!.getObject = getObject(globalStorage);
     }
     return globalStorage;
 }
 
-let caches: MMKV | null = null;
+let caches: KVDB = null!;
 export function getCaches() {
     if (!caches) {
+        // @ts-ignore
         caches = createMMKV({
             id: `caches`,
             path: fixPath(fs.cacheDirectory??''),
         });
+        caches.setObject = setObject(caches);
+        caches!.getObject = getObject(caches);
     }
     return caches;
 }
 
-let apiCache: MMKV | null = null;
+let apiCache: KVDB = null!;
 export function getApiCache() {
     if (!apiCache) {
+        // @ts-ignore
         apiCache = createMMKV({
             id: `api_cache`,
             path: fixPath(fs.cacheDirectory??''),
         });
+        apiCache.setObject = setObject(apiCache);
+        apiCache!.getObject = getObject(apiCache);
     }
     return apiCache;
-}
+}
+
+
+setTimeout(()=> {
+
+let lastClearTime = parseInt(globalStorage.getString("$__last_clear_time$_") || "0");
+const clearTimeout = 86400 * 3 * 1000;
+// 开启一直 15 分钏的定时器,用于清理缓存
+setInterval(() => {
+    let now = Date.now();
+    if (now - lastClearTime < clearTimeout) {
+        lastClearTime = now;
+        caches.getAllKeys().forEach(key => {
+            caches.getObject(key);
+        });
+        apiCache.getAllKeys().forEach(key => {
+            apiCache.getObject(key);
+        });
+        globalStorage.getAllKeys().forEach(key => {
+            globalStorage.getObject(key);
+        });
+
+        globalStorage.set("$__last_clear_time$_", now);
+    }
+}, 60 * 15 * 1000);
+}, 3000);