lv преди 1 месец
родител
ревизия
27278795ae
променени са 8 файла, в които са добавени 202 реда и са изтрити 100 реда
  1. 50 4
      src/app/(tabs)/_layout.tsx
  2. 50 43
      src/app/(tabs)/analytics.tsx
  3. 1 1
      src/app/_layout.tsx
  4. 7 7
      src/app/credit/upload.tsx
  5. 4 5
      src/components/ui/UIButton.tsx
  6. 27 0
      src/hooks/tabview.tsx
  7. 6 0
      src/utils/api.ts
  8. 57 40
      src/utils/cache.ts

+ 50 - 4
src/app/(tabs)/_layout.tsx

@@ -1,8 +1,10 @@
-import AppTabs from '@/components/app-tabs';
+import { Colors } from '@/constants/theme';
 import { useAuth } from '@/utils/auth';
-import { router } from 'expo-router';
+import { BlurView } from 'expo-blur';
+import { Image } from 'expo-image';
+import { router, Tabs } from 'expo-router';
 import React, { useEffect } from 'react';
-
+import { Platform, View } from 'react-native';
 export default function TabLayout() {
   const { authStatus } = useAuth();
 
@@ -13,5 +15,49 @@ export default function TabLayout() {
   }, [authStatus]);
 
 
-  return authStatus === 'auth' && <AppTabs />;
+  return authStatus === 'auth' &&
+
+    <Tabs screenOptions={{
+      headerShown: false,
+      tabBarActiveTintColor: Colors.tint,
+      tabBarStyle: { position: 'absolute' },
+      tabBarBackground: () => Platform.OS === 'ios' ? <BlurView tint='light' style={{ flex: 1 }} /> : <View style={{ flex: 1, backgroundColor: 'rgba(255, 255, 255, 0.9)' }} />,
+      lazy: true
+    }}>
+      <Tabs.Screen
+        name="index"
+        options={{
+          title: '首页',
+          tabBarIcon: ({ color }) => <Image style={{ width: 24, height: 24 }} tintColor={color} source={require('@/assets/images/tabIcons/home.png')} />
+        }}
+      />
+      <Tabs.Screen
+        name="customer"
+        options={{
+          title: '客户',
+          tabBarIcon: ({ color }) => <Image style={{ width: 24, height: 24 }} tintColor={color} source={require('@/assets/images/tabIcons/explore.png')} />
+        }}
+      />
+      <Tabs.Screen
+        name="analytics"
+        options={{
+          title: '分析',
+          tabBarIcon: ({ color }) => <Image style={{ width: 24, height: 24 }} tintColor={color} source={require('@/assets/images/tabIcons/explore.png')} />
+        }}
+      />
+      <Tabs.Screen
+        name="reports"
+        options={{
+          title: '报告',
+          tabBarIcon: ({ color }) => <Image style={{ width: 24, height: 24 }} tintColor={color} source={require('@/assets/images/tabIcons/explore.png')} />
+        }}
+      />
+      <Tabs.Screen
+        name="profile"
+        options={{
+          title: '我的',
+          tabBarIcon: ({ color }) => <Image style={{ width: 24, height: 24 }} source={require('@/assets/images/tabIcons/explore.png')} />
+        }}
+      />
+    </Tabs>;
 }

+ 50 - 43
src/app/(tabs)/analytics.tsx

@@ -1,34 +1,39 @@
 import { SectionHeader } from '@/components/ui/section-header';
+import api, { ListResponse } from '@/utils/api';
+import { useSWC } from '@/utils/cache';
+import { ActivityIndicator } from '@ant-design/react-native';
 import { Ionicons } from '@expo/vector-icons';
-import React, { useCallback, useState } from 'react';
+import { useFocusEffect } from 'expo-router';
+import React from 'react';
 import { Pressable, ScrollView, Text, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 import { UploadComponent } from '../credit/upload';
 
 
-type AnalysisMode = 'fast' | 'deep';
 
 type AnalysisRecord = {
   id: string;
-  customerName: string;
-  time: string;
-  status: '解析中' | '已完成' | '解析失败';
-  progress?: number;
+  createtime: string;
+  status: 'pending' | 'completed' | 'failed' | 'canceled';
   score?: string;
+  customer_name?: string;
+  name?: string;
 };
 
-const ANALYSIS_RECORDS: AnalysisRecord[] = [
-  { id: '1', customerName: '张德发', time: '今天 10:42', status: '已完成', score: 'B+' },
-  { id: '2', customerName: '钱进', time: '今天 10:05', status: '解析中', progress: 72 },
-  { id: '3', customerName: '李美华', time: '昨天 15:20', status: '已完成', score: 'A' },
-  { id: '4', customerName: '赵丽', time: '3天前', status: '解析失败' },
-];
-
 export default function AnalyticsScreen() {
-  const [analysisMode, setAnalysisMode] = useState<AnalysisMode>('fast');
-  const onUploadCredit = useCallback(() => {
+  const { data: list, loading, error, load, refresh } = useSWC<ListResponse<AnalysisRecord>>("credit_index_list", async () => {
+    return api.post("credit/list", { size: 10 });
+  }, {
+    cacheOnly: true,
+    cacheTimeout: 120,
+    autoStart: false,
+  })
+
+
+  useFocusEffect(() => {
+    load();
+  });
 
-  }, []);
   return (
     <SafeAreaView className="flex-1 bg-surface" edges={['top']}>
       <ScrollView
@@ -39,9 +44,6 @@ export default function AnalyticsScreen() {
         <Text className="mb-2 text-3xl font-extrabold tracking-tight text-on-surface">
           征信分析
         </Text>
-        <Text className="mb-6 text-base leading-7 text-on-surface-variant">
-          先选择客户,再上传征信文件,系统会自动生成评分和建议动作
-        </Text>
 
         <View className="mb-3 rounded-2xl border border-outline-variant bg-surface-container-lowest p-4">
           <Text className="mb-3 text-xs font-bold uppercase tracking-widest text-outline">
@@ -61,13 +63,14 @@ export default function AnalyticsScreen() {
           </Pressable>
         </View>
 
-        <UploadComponent onCompolete={onUploadCredit} />
+        <UploadComponent onCompolete={() => { refresh() }} />
 
 
 
         <SectionHeader title="解析记录" actionText="查看全部" />
         <View className="gap-3">
-          {ANALYSIS_RECORDS.map((record) => (
+          {loading === true && <ActivityIndicator />}
+          {list?.list?.map((record) => (
             <Pressable
               key={record.id}
               className="flex-row items-center rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-3.5"
@@ -76,26 +79,26 @@ export default function AnalyticsScreen() {
               })}
             >
               <View
-                className={`mr-3 h-11 w-11 items-center justify-center rounded-full ${record.status === '已完成'
+                className={`mr-3 h-11 w-11 items-center justify-center rounded-full ${record.status === 'completed'
                   ? 'bg-green-50'
-                  : record.status === '解析中'
+                  : record.status === 'pending'
                     ? 'bg-blue-50'
                     : 'bg-error-container'
                   }`}
               >
                 <Ionicons
                   name={
-                    record.status === '已完成'
+                    record.status === 'completed'
                       ? 'checkmark-circle'
-                      : record.status === '解析中'
+                      : record.status === 'pending'
                         ? 'hourglass-outline'
                         : 'alert-circle'
                   }
                   size={20}
                   color={
-                    record.status === '已完成'
+                    record.status === 'completed'
                       ? '#16a34a'
-                      : record.status === '解析中'
+                      : record.status === 'pending'
                         ? '#2563eb'
                         : '#ba1a1a'
                   }
@@ -105,36 +108,40 @@ export default function AnalyticsScreen() {
               <View className="flex-1">
                 <View className="mb-1 flex-row items-center justify-between gap-3">
                   <Text className="text-base font-bold text-on-surface">
-                    {record.customerName}
+                    {record.customer_name || record.name || '客户'}
                   </Text>
-                  <Text className="text-xs text-on-surface-variant">{record.time}</Text>
+                  <Text className="text-xs text-on-surface-variant">{record.createtime}</Text>
                 </View>
+                {(record.status === 'pending' || record.status == 'failed') && (
+                  <View>
+                    <View className="mb-2 flex-row items-center justify-between">
+                      <Text className="text-sm text-on-surface-variant">...</Text>
+                      <Text className="text-sm font-bold text-primary">
+                        {record.score}
+                      </Text>
+                    </View>
 
-                {record.status === '解析中' && record.progress != null ? (
+                  </View>)}
+
+                {record.status === 'completed' && (
                   <View>
                     <View className="mb-2 flex-row items-center justify-between">
-                      <Text className="text-sm text-on-surface-variant">正在解析征信数据...</Text>
+                      <Text className="text-sm text-on-surface-variant">...</Text>
                       <Text className="text-sm font-bold text-primary">
-                        {record.progress}%
+                        {record.score}
                       </Text>
                     </View>
                     <View className="h-1.5 rounded-full bg-surface-container">
                       <View
                         className="h-full rounded-full bg-primary-container"
-                        style={{ width: `${record.progress}%` }}
+                        style={{ width: '10%' }}
                       />
                     </View>
-                  </View>
-                ) : record.status === '已完成' ? (
-                  <View className="flex-row items-center gap-2">
-                    <Text className="text-sm text-on-surface-variant">征信评分</Text>
-                    <View className="rounded-full bg-primary-fixed px-2.5 py-1">
-                      <Text className="text-xs font-bold text-primary">{record.score}</Text>
-                    </View>
-                  </View>
-                ) : (
-                  <Text className="text-sm text-error">解析失败,请重新上传文件后重试</Text>
+                  </View>)}
+                {record.status === 'canceled' && (
+                  <Text className="text-sm text-error">解析失败,请联系管理员</Text>
                 )}
+
               </View>
 
               <Ionicons name="chevron-forward" size={18} color="#c3c6d7" />

+ 1 - 1
src/app/_layout.tsx

@@ -185,7 +185,7 @@ export default function RootLayout() {
               // headerLeft: ({ canGoBack }) => canGoBack && <Icon name="arrow-left" size={24} />,
               animation: 'ios_from_right'
             }}>
-              <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
+              <Stack.Screen name="(tabs)" options={{ headerShown: false, }} />
               <Stack.Screen name="sign-in" options={{ animation: 'fade_from_bottom' }} />
             </Stack>
           </AuthProvider>}

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

@@ -218,7 +218,7 @@ export function UploadScreen({ visible, onClose }: { visible: boolean, onClose:
 
 
 
-export function UploadComponent({ onCompolete }: { onCompolete: () => void }) {
+export function UploadComponent({ customerId, onCompolete }: { customerId?: number, onCompolete: () => void }) {
 
 
     const [state, setState] = useState(0);
@@ -243,13 +243,14 @@ export function UploadComponent({ onCompolete }: { onCompolete: () => void }) {
             await api.post('/credit/create', {
                 att: JSON.stringify(att),
             });
-            setState(2);
+            setState(0);
+            onCompolete?.();
             // eslint-disable-next-line @typescript-eslint/no-unused-vars
         } catch (err) {
             setState(0);
             Toast.fail('添加分析到队列失败,请重试');
         }
-    }, []);
+    }, [onCompolete]);
 
     const onSelect = useCallback((index: number) => {
         if (Platform.OS == 'android' && index == 2) {
@@ -296,16 +297,15 @@ export function UploadComponent({ onCompolete }: { onCompolete: () => void }) {
                     <View className="mb-3 h-14 w-14 items-center justify-center rounded-full bg-primary-fixed">
                         <Ionicons name="cloud-upload-outline" size={30} color="#004ac6" />
                     </View>
-                    <Text className="mb-1 text-base font-bold text-on-surface">{state == 1 ? '正在上传文档' : '点击上传征信报告'}</Text>
-                    <Text className={clsx("text-sm leading-6 text-on-surface-variant", {
+                    {state == 1 ? <ActivityIndicator text="正在上传文档" size='small' /> : <Text className="text-base font-bold text-on-surface">
+                        点击上传征信报告</Text>}
+                    <Text className={clsx("mt-1 text-sm leading-6 text-on-surface-variant", {
                         'opacity-50': state == 1
                     })}>
                         支持 PDF、图片格式,最大 20MB
                     </Text>
                 </Pressable>
             </View>
-
-            <UIButton loading={state === 1} disabled={state === 1} type="primary">开始分析</UIButton>
         </>
     );
 }

+ 4 - 5
src/components/ui/UIButton.tsx

@@ -27,8 +27,7 @@ function ButtonTextChild({ type, disabled, textClassName, children, loading }: U
         <ActivityIndicator color={disabled ? '#888' : Colors['on-primary']['DEFAULT']} />
         <Text className={clsx(
             textClassName,
-            'ml-1',
-            'text-xl font-bold', {
+            'text-sm font-semibold', {
             'text-on-primary': !disabled && type === 'primary',
             'text-primary': !disabled && type !== 'primary',
             'text-secondary': !disabled && type === 'second',
@@ -37,7 +36,7 @@ function ButtonTextChild({ type, disabled, textClassName, children, loading }: U
         })}>{children}</Text>
     </View> : <Text className={clsx(
         textClassName,
-        'text-xl font-bold', {
+        'text-sm font-semibold', {
         'text-on-primary': !disabled && type === 'primary',
         'text-primary': !disabled && type !== 'primary',
         'text-secondary': !disabled && type === 'second',
@@ -62,7 +61,7 @@ export default function UIButton({ onPress, href, type, icon, className, style,
         }
     }, []);
     const inner = useMemo(() => <Pressable
-        className={clsx('h-14 rounded-3xl flex-row justify-center items-center border-2 opacity-100 active:opacity-75 active:scale-95',
+        className={clsx('flex-row justify-center items-center rounded-xl py-1.5 border-2 opacity-100 active:opacity-75 active:scale-95',
             className, {
             'bg-primary border-primary': type === 'primary',
             'bg-surface border-primary/25': !type,
@@ -75,7 +74,7 @@ export default function UIButton({ onPress, href, type, icon, className, style,
         disabled={disabled}
         onPress={handlePress}
         style={style}>
-        {typeof icon === 'string' ? <Icon name={icon as IconNames} size={24} style={{ marginRight: 4, color: disabled ? Colors['on-surface']['variant'] : type === 'primary' ? '#fff' : type === 'second' ? Colors.secondary.DEFAULT : Colors.primary.DEFAULT }} /> : icon}
+        {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])
     if (typeof href !== 'undefined') {

+ 27 - 0
src/hooks/tabview.tsx

@@ -0,0 +1,27 @@
+// hooks/useLazyScreen.ts
+
+import { useFocusEffect } from 'expo-router';
+import { useCallback, useState } from 'react';
+
+/** 懒加载:首次访问才渲染,离开后保留 */
+export function useLazyLoad() {
+    const [hasLoaded, setHasLoaded] = useState(false);
+    useFocusEffect(
+        useCallback(() => {
+            if (!hasLoaded) setHasLoaded(true);
+        }, [hasLoaded])
+    );
+    return hasLoaded;
+}
+
+/** 销毁模式:每次离开销毁,进入重新 mount */
+export function useUnmountOnBlur() {
+    const [isVisible, setIsVisible] = useState(false);
+    useFocusEffect(
+        useCallback(() => {
+            setIsVisible(true);
+            return () => setIsVisible(false);
+        }, [])
+    );
+    return isVisible;
+}

+ 6 - 0
src/utils/api.ts

@@ -70,6 +70,12 @@ interface ApiResponse<T> {
     data?: T;
 }
 
+export interface ListResponse<T = unknown> {
+    start?: number;
+    size?: string;
+    list?: T[];
+}
+
 const verString = (() => {
     const expoConfig = Constants.expoConfig;
     const versionName = expoConfig?.version || '';

+ 57 - 40
src/utils/cache.ts

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Platform } from "react-native";
 import { getApiCache, getGlobalStorage } from "./storage";
 
@@ -9,32 +9,36 @@ interface UseSWCOPtions<T> {
     onLoad?: (data: T) => void;
     cacheOnly?: boolean;
     cacheTimeout?: number;
+    // 自动开始加载
+    autoStart?: boolean;
 }
 // 该hook用于在组件中通过key和异步action获取数据,并自动处理加载、错误和数据状态
 
-export function useSWC<T>(key: string, action: () => Promise<T>, options?: UseSWCOPtions<T>) {
-    const optionsRef = useRef(options);
-    optionsRef.current = options;
-    
+export function useSWC<T, S extends any[] = any>(key: string, action: () => Promise<T>, options?: UseSWCOPtions<T>) {
+    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(getApiCache().getString(`$swc-${key}`) || "null") as T;
+            const data = JSON.parse(caches.getString(keyRef.current) || "null") as T;
             if (!data) {
                 return undefined;
             }
-            let ttl = optionsRef.current?.cacheTimeout || MAX_CACHE_TIME;
+            let ttl = optionsRef.current.cacheTimeout || MAX_CACHE_TIME;
             // @ts-ignore
             let cacheTime = data.$__cacheTime;
             if (Date.now() - cacheTime > ttl * 1000) {
-                return undefined;
+                throw "expired";
             }
             return data;
         // eslint-disable-next-line @typescript-eslint/no-unused-vars
         } catch (_) {
+            caches.remove(keyRef.current);
             return undefined;
         }
     });
-    const [loading, setLoading] = useState<boolean>(true);
+    const [loading, setLoading] = useState<true | 'background' | false>(true);
     const [error, setError] = useState<any>(null);
 
     const actionRef = useRef(action);
@@ -42,47 +46,60 @@ export function useSWC<T>(key: string, action: () => Promise<T>, options?: UseSW
 
     const dataRef = useRef(data);
     dataRef.current = data;
-    const keyRef = useRef(key);
-
+    const isMountedRef = useRef(true);
+    const load = useCallback(async ()=> {
+        setLoading(dataRef.current ? 'background' : true);
+        setError(null);
+         
+        const options = optionsRef.current;
+        const key = keyRef.current;
+        try {
+            const result = await actionRef.current();
+            // @ts-ignore
+            result.$__cacheTime = Date.now();
+            result && getApiCache().set(key, JSON.stringify(result));
+            if (isMountedRef.current) {
+                options?.onLoad?.(result);
+                setData(result);
+                setLoading(false);
+            }
+            
+        } catch(err) {
+            if (isMountedRef.current) {
+                options?.onError?.(err);
+                setError(err);
+                setLoading(false);
+            }
+        }
+    }, [])
     useEffect(() => {
-        let isMounted = true;
-        if (dataRef.current && optionsRef.current?.cacheOnly) {
+        if (dataRef.current && optionsRef.current.cacheOnly) {
 
             setLoading(false);
             setError(null);
             return;
         }
-        setLoading(true);
-        setError(null);
-         
-        const options = optionsRef.current;
-        const key = keyRef.current;
-        actionRef.current()
-            .then((result) => {
-                // @ts-ignore
-                result.$__cacheTime = Date.now();
-                result && getApiCache().set(`$swc-${key}`, JSON.stringify(result));
-                if (isMounted) {
-                    options?.onLoad?.(result);
-                    setData(result);
-                    setLoading(false);
-                }
-            })
-            .catch((err) => {
-                if (isMounted) {
-                    options?.onError?.(err);
-                    setError(err);
-                    setLoading(false);
-                }
-            });
-
+        
+        // @ts-ignore
+        if ((optionsRef.current.autoStart !== false || optionsRef.current.autoStart)) {
+            load();
+        }
+     
         return () => {
-            isMounted = false;
+            isMountedRef.current = false;
         };
-     
     }, []);
 
-    return { data, loading, error };
+    const getOrLoad = useCallback(async ()=> {
+        if (dataRef.current && optionsRef.current.cacheOnly) {
+            setLoading(false);
+            setError(null);
+            return;
+        }
+        await load();
+    }, [load]);
+
+    return { data, loading, error, load: getOrLoad, refresh: load };
 }
 
 export const MAX_CACHE_TIME = 99999999999999;