lv 1 개월 전
부모
커밋
af03b1a06f
5개의 변경된 파일274개의 추가작업 그리고 225개의 파일을 삭제
  1. 12 94
      src/app/(tabs)/analytics.tsx
  2. 1 1
      src/app/(tabs)/index.tsx
  3. 220 121
      src/app/credit/upload.tsx
  4. 36 9
      src/components/ui/UIButton.tsx
  5. 5 0
      src/utils/tsutils.ts

+ 12 - 94
src/app/(tabs)/analytics.tsx

@@ -1,9 +1,10 @@
 import { SectionHeader } from '@/components/ui/section-header';
-import { Toast } from '@ant-design/react-native';
 import { Ionicons } from '@expo/vector-icons';
-import React, { useState } from 'react';
+import React, { useCallback, useState } 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';
 
@@ -25,7 +26,9 @@ const ANALYSIS_RECORDS: AnalysisRecord[] = [
 
 export default function AnalyticsScreen() {
   const [analysisMode, setAnalysisMode] = useState<AnalysisMode>('fast');
+  const onUploadCredit = useCallback(() => {
 
+  }, []);
   return (
     <SafeAreaView className="flex-1 bg-surface" edges={['top']}>
       <ScrollView
@@ -58,93 +61,9 @@ export default function AnalyticsScreen() {
           </Pressable>
         </View>
 
-        <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">
-            上传征信文件
-          </Text>
-          <Pressable
-            onPress={() => Toast.info('演示页暂未接入真实上传能力')}
-            className="items-center rounded-2xl border-2 border-dashed border-outline-variant/40 bg-surface-container-low/50 px-6 py-8"
-            style={({ pressed }) => ({
-              opacity: pressed ? 0.92 : 1,
-            })}
-          >
-            <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">点击上传征信报告</Text>
-            <Text className="text-sm leading-6 text-on-surface-variant">
-              支持 PDF、图片格式,最大 20MB
-            </Text>
-          </Pressable>
-        </View>
+        <UploadComponent onCompolete={onUploadCredit} />
 
-        <View className="mb-5 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">
-            解析模式
-          </Text>
-          <View className="flex-row gap-2.5">
-            {[
-              {
-                key: 'fast' as const,
-                title: '快速解析',
-                description: '约 30 秒,先看核心指标',
-                icon: 'flash-outline' as const,
-              },
-              {
-                key: 'deep' as const,
-                title: '深度分析',
-                description: '约 90 秒,生成完整建议',
-                icon: 'layers-outline' as const,
-              },
-            ].map((item) => {
-              const active = analysisMode === item.key;
-              return (
-                <Pressable
-                  key={item.key}
-                  onPress={() => setAnalysisMode(item.key)}
-                  className={`flex-1 rounded-2xl px-4 py-4 ${
-                    active ? 'bg-primary-container' : 'bg-surface-container-low'
-                  }`}
-                  style={({ pressed }) => ({
-                    opacity: pressed ? 0.9 : 1,
-                  })}
-                >
-                  <Ionicons
-                    name={item.icon}
-                    size={24}
-                    color={active ? '#ffffff' : '#737686'}
-                  />
-                  <Text
-                    className={`mt-3 text-base font-bold ${
-                      active ? 'text-on-primary' : 'text-on-surface'
-                    }`}
-                  >
-                    {item.title}
-                  </Text>
-                  <Text
-                    className={`mt-1.5 text-sm leading-6 ${
-                      active ? 'text-on-primary/80' : 'text-on-surface-variant'
-                    }`}
-                  >
-                    {item.description}
-                  </Text>
-                </Pressable>
-              );
-            })}
-          </View>
-        </View>
 
-        <Pressable
-          onPress={() => Toast.success('解析任务已加入队列')}
-          className="mb-8 items-center rounded-2xl bg-primary py-3.5"
-          style={({ pressed }) => ({
-            opacity: pressed ? 0.88 : 1,
-            transform: [{ scale: pressed ? 0.985 : 1 }],
-          })}
-        >
-          <Text className="text-base font-bold text-on-primary">开始解析</Text>
-        </Pressable>
 
         <SectionHeader title="解析记录" actionText="查看全部" />
         <View className="gap-3">
@@ -157,13 +76,12 @@ export default function AnalyticsScreen() {
               })}
             >
               <View
-                className={`mr-3 h-11 w-11 items-center justify-center rounded-full ${
-                  record.status === '已完成'
-                    ? 'bg-green-50'
-                    : record.status === '解析中'
-                      ? 'bg-blue-50'
-                      : 'bg-error-container'
-                }`}
+                className={`mr-3 h-11 w-11 items-center justify-center rounded-full ${record.status === '已完成'
+                  ? 'bg-green-50'
+                  : record.status === '解析中'
+                    ? 'bg-blue-50'
+                    : 'bg-error-container'
+                  }`}
               >
                 <Ionicons
                   name={

+ 1 - 1
src/app/(tabs)/index.tsx

@@ -8,7 +8,7 @@ import { Ionicons } from '@expo/vector-icons';
 import { useState } from 'react';
 import { Pressable, ScrollView, Text, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
-import UploadScreen from '../credit/upload';
+import { UploadScreen } from '../credit/upload';
 
 
 

+ 220 - 121
src/app/credit/upload.tsx

@@ -1,12 +1,13 @@
 
 import UIButton from "@/components/ui/UIButton";
 import { Colors } from "@/constants/theme";
-import { ActivityIndicator, Icon, Modal, Toast } from "@ant-design/react-native";
+import { ActionSheet, ActivityIndicator, Icon, Modal, Toast } from "@ant-design/react-native";
 import { Ionicons } from '@expo/vector-icons';
 import { Platform, Pressable, Text, View } from "react-native";
 
 import api from "@/utils/api";
 import { openSystemSettings } from "@/utils/os";
+import clsx from "clsx";
 import { DocumentPickerAsset, getDocumentAsync } from 'expo-document-picker';
 import { File } from 'expo-file-system';
 import { ImagePickerAsset, launchCameraAsync, launchImageLibraryAsync, requestCameraPermissionsAsync, requestMediaLibraryPermissionsAsync } from 'expo-image-picker';
@@ -14,7 +15,123 @@ import { Link } from "expo-router";
 import { useCallback, useEffect, useState } from "react";
 
 
-export default function UploadScreen({ visible, onClose }: { visible: boolean, onClose: () => void }) {
+
+const takePhoto = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
+    const permission = await requestCameraPermissionsAsync();
+
+    if (!permission.granted) {
+        Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
+            {
+                text: "确认",
+                onPress: openSystemSettings,
+            }
+        ]);
+        // 跳转到 ios/android 相关设置页面
+        return;
+    }
+    const l = Toast.loading('正在打开相机...');
+    try {
+        const result = await launchCameraAsync({
+            allowsEditing: false, // 是否允许裁剪
+            quality: 0.9,        // 照片质量
+        });
+
+        if (!result.canceled) {
+            upload(result.assets);
+        }
+    } catch (err) {
+        console.warn(err);
+        Toast.fail('打开相机失败,请重试');
+    }
+    finally {
+        Toast.remove(l);
+    }
+}
+
+const picImg = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
+    const permission = await requestMediaLibraryPermissionsAsync();
+
+    if (!permission.granted) {
+        Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
+            {
+                text: "确认",
+                onPress: openSystemSettings,
+            }
+        ]);
+        // 跳转到 ios/android 相关设置页面
+        return;
+    }
+    const l = Toast.loading('正在打开相册...');
+    try {
+        let result = await launchImageLibraryAsync({
+            mediaTypes: ['images'], // 只选图片
+            allowsEditing: false,
+            quality: .9, // 质量 0~1
+        });
+
+        if (!result.canceled) {
+            upload(result.assets);
+        }
+    } catch (err) {
+        console.warn(err);
+        Toast.fail('打开相册失败,请重试');
+    } finally {
+
+        Toast.remove(l);
+    }
+}
+
+const pickDoc = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
+    const l = Toast.loading('正在打开相册...');
+
+    try {
+        let result = await getDocumentAsync({
+            type: [
+                // PDF
+                'application/pdf',
+
+                // Word 文档
+                'application/msword',
+                'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+
+                // Excel 表格
+                'application/vnd.ms-excel',
+                'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+
+                // WPS 文字 / WPS 表格
+                'application/wps-office.doc',
+                'application/wps-office.docx',
+                'application/wps-office.xls',
+                'application/wps-office.xlsx',
+                'application/wps-office.et',
+                'application/wps-office.wps',
+
+                // 纯文本
+                'text/plain',
+
+                // HTML
+                'text/html',
+
+                // 全部图片(jpg/png/gif/webp 等)
+                'image/*'
+            ],
+            copyToCacheDirectory: true, // 复制到应用缓存目录
+        });
+
+        if (!result.canceled) {
+            upload(result.assets);
+        }
+    } catch (err) {
+        console.warn(err);
+        Toast.fail('打开文件失败,请重试');
+    }
+    finally {
+        Toast.remove(l);
+    }
+};
+
+
+export function UploadScreen({ visible, onClose }: { visible: boolean, onClose: () => void }) {
 
 
     useEffect(() => {
@@ -51,120 +168,6 @@ export default function UploadScreen({ visible, onClose }: { visible: boolean, o
         }
     }, []);
 
-    const takePhoto = useCallback(async () => {
-        const permission = await requestCameraPermissionsAsync();
-
-        if (!permission.granted) {
-            Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
-                {
-                    text: "确认",
-                    onPress: openSystemSettings,
-                }
-            ]);
-            // 跳转到 ios/android 相关设置页面
-            return;
-        }
-        const l = Toast.loading('正在打开相机...');
-        try {
-            const result = await launchCameraAsync({
-                allowsEditing: false, // 是否允许裁剪
-                quality: 0.9,        // 照片质量
-            });
-
-            if (!result.canceled) {
-                upload(result.assets);
-            }
-        } catch (err) {
-            console.warn(err);
-            Toast.fail('打开相机失败,请重试');
-        }
-        finally {
-            Toast.remove(l);
-        }
-    }, [upload]);
-
-    const picImg = useCallback(async () => {
-        const permission = await requestMediaLibraryPermissionsAsync();
-
-        if (!permission.granted) {
-            Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
-                {
-                    text: "确认",
-                    onPress: openSystemSettings,
-                }
-            ]);
-            // 跳转到 ios/android 相关设置页面
-            return;
-        }
-        const l = Toast.loading('正在打开相册...');
-        try {
-            let result = await launchImageLibraryAsync({
-                mediaTypes: ['images'], // 只选图片
-                allowsEditing: false,
-                quality: .9, // 质量 0~1
-            });
-
-            if (!result.canceled) {
-                upload(result.assets);
-            }
-        } catch (err) {
-            console.warn(err);
-            Toast.fail('打开相册失败,请重试');
-        } finally {
-
-            Toast.remove(l);
-        }
-    }, [upload]);
-
-    const pickDoc = useCallback(async () => {
-        const l = Toast.loading('正在打开相册...');
-
-        try {
-            let result = await getDocumentAsync({
-                type: [
-                    // PDF
-                    'application/pdf',
-
-                    // Word 文档
-                    'application/msword',
-                    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-
-                    // Excel 表格
-                    'application/vnd.ms-excel',
-                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-
-                    // WPS 文字 / WPS 表格
-                    'application/wps-office.doc',
-                    'application/wps-office.docx',
-                    'application/wps-office.xls',
-                    'application/wps-office.xlsx',
-                    'application/wps-office.et',
-                    'application/wps-office.wps',
-
-                    // 纯文本
-                    'text/plain',
-
-                    // HTML
-                    'text/html',
-
-                    // 全部图片(jpg/png/gif/webp 等)
-                    'image/*'
-                ],
-                copyToCacheDirectory: true, // 复制到应用缓存目录
-            });
-
-            if (!result.canceled) {
-                upload(result.assets);
-            }
-        } catch (err) {
-            console.warn(err);
-            Toast.fail('打开文件失败,请重试');
-        }
-        finally {
-            Toast.remove(l);
-        }
-    }, [upload]);
-
     return (
         <Modal visible={visible} transparent={true} animationType="slide">
             <View className="flex-row items-center">
@@ -175,10 +178,10 @@ export default function UploadScreen({ visible, onClose }: { visible: boolean, o
                 <View className="w-72 h-auto px-6">
 
                     <Text className="mt-2 text-2xl">请选择征信上传方式</Text>
-                    <UIButton title="从本机文件" icon="folder" onPress={pickDoc} className="mt-8" />
-                    {Platform.OS === 'ios' && <UIButton title="从本地相册" icon="picture" onPress={picImg} className="mt-4" />}
+                    <UIButton title="从本机文件" icon="folder" onPress={() => pickDoc(upload)} className="mt-8" />
+                    {Platform.OS === 'ios' && <UIButton title="从手机相册" icon="picture" onPress={() => picImg(upload)} className="mt-4" />}
 
-                    <UIButton title="拍照上传" icon="camera" onPress={takePhoto} className="mt-4" />
+                    <UIButton title="拍照上传" icon="camera" onPress={() => takePhoto(upload)} className="mt-4" />
                 </View>)}
             {
                 state === 1 && (
@@ -204,10 +207,106 @@ export default function UploadScreen({ visible, onClose }: { visible: boolean, o
                 )
             }
             <View className="h-8" />
-            <Pressable hitSlop={8} className="absolute top-0 right-4" onPress={() => { onClose(); }}>
+            {state != 1 && <Pressable hitSlop={8} className="absolute top-0 right-4" onPress={() => { onClose(); }}>
                 <Icon name="close" size={32} />
-            </Pressable>
+            </Pressable>}
         </Modal >
     );
 }
 
+
+
+
+
+export function UploadComponent({ onCompolete }: { onCompolete: () => void }) {
+
+
+    const [state, setState] = useState(0);
+
+    const upload = useCallback(async (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => {
+        setState(1);
+        let att: { url: string, fullurl: string, attid: any } = null!;
+        try {
+            const file = new File(assets[0].uri);
+            // file.type = item.mimeType || 'application/octet-stream';
+            const formData = new FormData();
+            formData.append('file', file);
+            att = await api.uploadFile<{ url: string, fullurl: string, attid: any }>('common/upload', { body: formData });
+
+        } catch (err) {
+            setState(0);
+            console.warn(err);
+            Toast.fail('上传失败,请重试');
+            return;
+        }
+        try {
+            await api.post('/credit/create', {
+                att: JSON.stringify(att),
+            });
+            setState(2);
+            // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        } catch (err) {
+            setState(0);
+            Toast.fail('添加分析到队列失败,请重试');
+        }
+    }, []);
+
+    const onSelect = useCallback((index: number) => {
+        if (Platform.OS == 'android' && index == 2) {
+            return;
+        }
+        if (Platform.OS == 'ios' && index == 3) {
+            return;
+        }
+        if (index == 0) {
+            pickDoc(upload);
+        }
+        if (index == 1) {
+            if (Platform.OS == 'ios') {
+                picImg(upload);
+            } else {
+                takePhoto(upload);
+            }
+        }
+        if (index == 2) {
+            takePhoto(upload);
+        }
+
+    }, []);
+    const selectFile = useCallback(() => {
+        ActionSheet.showActionSheetWithOptions({
+            title: "请选择征信上传方式",
+            cancelButtonIndex: 3,
+            options: Platform.OS == 'ios' ? ['从本机文件', '从手机相册', '拍照上传', '取消'] : ['从本机文件', '拍照上传', '取消']
+        }, onSelect);
+    }, []);
+
+    return (
+        <>
+            <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">
+                    上传征信文件
+                </Text>
+                <Pressable
+                    onPress={selectFile}
+                    disabled={state == 1}
+                    className="items-center rounded-2xl border-2 border-dashed border-outline-variant/40 bg-surface-container-low/50 px-6 py-8 disabled:opacity-50"
+
+                >
+                    <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", {
+                        'opacity-50': state == 1
+                    })}>
+                        支持 PDF、图片格式,最大 20MB
+                    </Text>
+                </Pressable>
+            </View>
+
+            <UIButton loading={state === 1} disabled={state === 1} type="primary">开始分析</UIButton>
+        </>
+    );
+}
+

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

@@ -1,15 +1,16 @@
 import { Colors } from '@/constants/theme';
-import { Icon } from '@ant-design/react-native';
+import { isPromise } from '@/utils/tsutils';
+import { ActivityIndicator, Icon } from '@ant-design/react-native';
 import { type IconNames } from '@ant-design/react-native/lib/icon';
 import { clsx } from 'clsx';
 import { Href, Link } from "expo-router";
-import React, { useMemo } from "react";
-import { Pressable, Text, ViewStyle } from "react-native";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { Pressable, Text, View, ViewStyle } from "react-native";
 
 interface UIButtonProps {
     children?: string | React.ReactNode;
     title?: string | React.ReactNode;
-    onPress?: () => void;
+    onPress?: () => PromiseLike<unknown> | unknown;
     disabled?: boolean;
     className?: string;
     textClassName?: string;
@@ -17,11 +18,24 @@ interface UIButtonProps {
     type?: 'primary' | 'second' | 'link'
     icon?: IconNames | React.ReactNode;
     style?: ViewStyle;
+    loading?: boolean;
 }
 
-function ButtonTextChild({ type, disabled, textClassName, children }: UIButtonProps) {
+function ButtonTextChild({ type, disabled, textClassName, children, loading }: UIButtonProps) {
 
-    return <Text className={clsx(
+    return loading ? <View className='flex-row justify-center items-center'>
+        <ActivityIndicator color={disabled ? '#888' : Colors['on-primary']['DEFAULT']} />
+        <Text className={clsx(
+            textClassName,
+            'ml-1',
+            'text-xl font-bold', {
+            'text-on-primary': !disabled && type === 'primary',
+            'text-primary': !disabled && type !== 'primary',
+            'text-secondary': !disabled && type === 'second',
+            'text-on-surface': !disabled && !type,
+            'text-on-surface-variant/65 font-normal': disabled,
+        })}>{children}</Text>
+    </View> : <Text className={clsx(
         textClassName,
         'text-xl font-bold', {
         'text-on-primary': !disabled && type === 'primary',
@@ -32,8 +46,21 @@ function ButtonTextChild({ type, disabled, textClassName, children }: UIButtonPr
     })}>{children}</Text>
 }
 
-export default function UIButton({ onPress, href, type, icon, className, style, disabled, children, title, textClassName }: UIButtonProps) {
+export default function UIButton({ onPress, href, type, icon, className, style, disabled, children, title, textClassName, loading }: UIButtonProps) {
+    const [ding, setDing] = useState(loading);
+    useEffect(() => {
+        setDing(loading);
+    }, [loading])
     children = children || title;
+    const handlePress = useCallback(() => {
+        const res = onPress?.() as Promise<any> || undefined;
+        if (isPromise(res)) {
+            setDing(true);
+            res.finally(() => {
+                setDing(false);
+            })
+        }
+    }, []);
     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, {
@@ -46,10 +73,10 @@ export default function UIButton({ onPress, href, type, icon, className, style,
             'bg-bg-gray-400 border-gray-200': disabled && type === 'second'
         })}
         disabled={disabled}
-        onPress={onPress}
+        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 children == 'string' ? <ButtonTextChild type={type} disabled={disabled} textClassName={textClassName}>{children}</ButtonTextChild> : children}
+        {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') {
         return <Link href={href} asChild>

+ 5 - 0
src/utils/tsutils.ts

@@ -0,0 +1,5 @@
+export function isPromise(obj: any) {
+    return (obj instanceof Promise ||
+        Object.prototype.toString.call(obj)
+        === "[object Promisel");
+}