yblunan@gmail.com 1 ヶ月 前
コミット
f64f5891ec

+ 10 - 20
src/app/(tabs)/index.tsx

@@ -1,7 +1,7 @@
 import { SectionHeader } from '@/components/ui/section-header';
 import { StatusBadge } from '@/components/ui/status-badge';
 import { Ionicons } from '@expo/vector-icons';
-import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Pressable, ScrollView, Text, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 type QuickAction = {
   icon: keyof typeof Ionicons.glyphMap;
@@ -254,26 +254,16 @@ export default function HomeScreen() {
           </View>
         </View>
       </ScrollView>
-        <View
-          className="absolute z-50 h-9 w-24 items-center justify-center rounded-full bg-primary"
-          style={styles.aiButton}
-        >
-            <Text className="text-lg font-bold text-white">
-            AI 助理
-            </Text>
-        </View>
+      <View
+        className="absolute z-50 h-9 w-24 right-12 bottom-24 android:bottom-10 items-center justify-center rounded-full bg-primary"
+
+      >
+        <Text className="text-lg font-bold text-white">
+          AI 助理
+        </Text>
+      </View>
     </SafeAreaView>
   );
 }
 
-const styles = StyleSheet.create({
-  aiButton: {
-    right: 24,
-    bottom: 128,
-    shadowColor: '#2563eb',
-    shadowOffset: { width: 10, height: 10 },
-    shadowOpacity: 0.28,
-    shadowRadius: 14,
-    elevation: 10,
-  },
-});
+

+ 18 - 21
src/app/sign-in.tsx

@@ -47,14 +47,14 @@ export default function SignInScreen() {
   };
 
   const handleLogin = async () => {
-   
+
     setFlushAgree(false);
     if (mobile.trim().length !== 11) {
       Toast.fail('请输入正确的手机号');
       return;
     }
 
-  
+
     if (authMode === 'sms' && code.trim().length !== 6) {
       Toast.fail('请输入 6 位验证码');
       return;
@@ -65,11 +65,11 @@ export default function SignInScreen() {
       return;
     }
 
-     if (!agreed) {
+    if (!agreed) {
       Toast.fail('请先阅读并同意协议');
       // scroll to agree
       setFlushAgree(true);
-      scrollView.current?.scrollToEnd({animated: true});
+      scrollView.current?.scrollToEnd({ animated: true });
       return;
     }
 
@@ -103,10 +103,10 @@ export default function SignInScreen() {
         behavior={Platform.OS === 'ios' ? 'padding' : undefined}
       >
         <ScrollView
-        ref={scrollView}
+          ref={scrollView}
           className="flex-1"
           contentContainerClassName="px-8"
-          contentInset={{top: insets.top ?? 8, bottom: insets.bottom ?? 8}}
+          contentInset={{ top: insets.top ?? 8, bottom: insets.bottom ?? 8 }}
           keyboardShouldPersistTaps="handled"
           showsVerticalScrollIndicator={false}
         >
@@ -140,18 +140,16 @@ export default function SignInScreen() {
                         key={mode}
                         disabled={loading}
                         onPress={() => setAuthMode(mode as 'sms' | 'password')}
-                        className={`flex-1 rounded-xl px-4 py-3 ${
-                          active ? 'bg-surface-container-lowest' : ''
-                        }`}
+                        className={`flex-1 rounded-xl px-4 py-3 ${active ? 'bg-surface-container-lowest' : ''
+                          }`}
                         style={({ pressed }) => ({
                           opacity: loading ? 0.5 : pressed ? 0.86 : 1,
                           transform: [{ scale: pressed ? 0.99 : 1 }],
                         })}
                       >
                         <Text
-                          className={`text-center text-base font-bold ${
-                            active ? 'text-primary' : 'text-on-surface-variant'
-                          }`}
+                          className={`text-center text-base font-bold ${active ? 'text-primary' : 'text-on-surface-variant'
+                            }`}
                         >
                           {label}
                         </Text>
@@ -226,7 +224,7 @@ export default function SignInScreen() {
                 <Button type='primary' onPress={handleLogin}>登录</Button>
 
                 <View className="flex-row items-center justify-between px-3">
-                  <Pressable hitSlop={8} onPress={() => router.push({pathname: '/sign-up', params: {signIn: 1, redirectTo}})}>
+                  <Pressable hitSlop={8} onPress={() => router.push({ pathname: '/sign-up', params: { signIn: 1, redirectTo } })}>
                     <Text className="text-lg font-medium text-on-surface-variant">
                       注册账号
                     </Text>
@@ -271,18 +269,17 @@ export default function SignInScreen() {
                 ))}
               </View>
 
-              <View className={`flex-row items-start gap-3 px-2 rounded-md border-transparent border-2 transition delay-300 ${flushAgree ? ' border-primary/50' : ''}`}>
+              <View className={`flex-row items-start gap-3 px-2 rounded-md border-2 transition-colors duration-700 ${flushAgree ? ' border-primary/50' : 'border-transparent'}`}>
                 <Pressable
                   onPress={() => setAgreed((value) => !value)}
                   hitSlop={8}
                   className="pt-1"
                 >
                   <View
-                    className={`h-6 w-6 mt-2 items-center justify-center border-2 border-primary/50 rounded-full cursor-pointer ${
-                      agreed
-                        ? 'border-primary bg-primary'
-                        : 'border-outline-variant bg-surface-container-low'
-                    }`}
+                    className={`h-6 w-6 mt-2 items-center justify-center border-2 border-primary/50 rounded-full cursor-pointer ${agreed
+                      ? 'border-primary bg-primary'
+                      : 'border-outline-variant bg-surface-container-low'
+                      }`}
                   >
                     {agreed ? (
                       <Ionicons name="checkmark" size={14} color="#ffffff" />
@@ -293,12 +290,12 @@ export default function SignInScreen() {
                   登录即代表您已阅读并同意
                   <Link href={{
                     pathname: '/web',
-                    params: {uri: site}
+                    params: { uri: site }
                   }}>
                     <Text className="font-semibold text-primary">《用户服务协议》</Text>
                   </Link>
-                  <Text className="font-semibold text-primary cursor-pointer" onPress={()=>openBrowserAsync(`${site}`)}>《隐私政策》</Text>
+                  <Text className="font-semibold text-primary cursor-pointer" onPress={() => openBrowserAsync(`${site}`)}>《隐私政策》</Text>
                   ,以及授权该应用获取您的公开信息。
                 </Text>
               </View>

+ 5 - 3
src/app/sign-up.tsx

@@ -1,3 +1,4 @@
+import CaptchaBox from '@/components/captcha-box';
 import { site } from '@/config.json';
 import { signUp, useAuth } from '@/utils/auth';
 import { Button, Toast } from '@ant-design/react-native';
@@ -39,7 +40,7 @@ export default function SignUpScreen() {
   const scrollView = useRef<ScrollView>(null);
   const { setToken } = useAuth();
 
-  const {signIn, } = useLocalSearchParams<{ signIn: string; redirectTo?: string }>();
+  const { signIn, } = useLocalSearchParams<{ signIn: string; redirectTo?: string }>();
   const handleSendCode = () => {
     if (mobile.trim().length !== 11) {
       Toast.fail('请先输入 11 位手机号');
@@ -81,7 +82,7 @@ export default function SignUpScreen() {
       return;
     }
 
-    
+
 
     if (!agreed) {
       Toast.fail('请先阅读并同意协议');
@@ -100,7 +101,7 @@ export default function SignUpScreen() {
       });
       setToken(token);
       Toast.success('登录成功');
-      
+
       if (signIn) {
         router.dismissTo("/");
       } else {
@@ -325,6 +326,7 @@ export default function SignUpScreen() {
           </View>
         </ScrollView>
       </KeyboardAvoidingView>
+      <CaptchaBox visible onClose={alert} />
     </View>
   );
 }

+ 6 - 6
src/components/app-tabs.tsx

@@ -1,17 +1,17 @@
+import { Colors } from '@/constants/theme';
 import { NativeTabs } from 'expo-router/unstable-native-tabs';
 import React from 'react';
 
-import { Colors } from '@/constants/theme';
 
 export default function AppTabs() {
-  const colors = Colors;
 
   return (
     <NativeTabs
-      backgroundColor={colors.background}
-      indicatorColor={colors.backgroundElement}
-      tintColor={colors.brand_primary}
->
+      labelVisibilityMode="labeled"
+      backgroundColor={Colors.background}
+      indicatorColor={Colors.backgroundElement}
+      tintColor={Colors.brand_primary}
+    >
       <NativeTabs.Trigger name="index">
         <NativeTabs.Trigger.Label>首页</NativeTabs.Trigger.Label>
         <NativeTabs.Trigger.Icon

+ 56 - 16
src/components/captcha-box.tsx

@@ -1,23 +1,63 @@
 import { site } from '@/config.json';
-import { Modal, View } from "@ant-design/react-native";
-import { Image } from "expo-image";
-import { useState } from "react";
-import { TextInput } from 'react-native-gesture-handler';
+import api from '@/utils/api';
+import { Button, Icon, Modal } from "@ant-design/react-native";
+import { Image } from 'expo-image';
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Pressable, TextInput, View } from 'react-native';
 
-export default function CaptchaBox({onClose, visible}: {onClose: (code?: string)=>void; visible: boolean}) {
-    const [t, setT] = useState(1);
+function bin2uri(arrayBuffer: ArrayBuffer) {
+    return new Promise<string>((resolve, reject) => {
+        const blob = new Blob([arrayBuffer], { type: 'image/png' });
+        const reader = new FileReader();
+        reader.onloadend = () => {
+            resolve(reader.result as string);
+        };
+        reader.onerror = reject;
+        reader.readAsDataURL(blob);
+    });
+}
+
+
+export default function CaptchaBox({ onClose, visible }: { onClose: (code?: string) => void; visible: boolean }) {
+    const [uri, setUri] = useState<string>(null!);
     const [value, setValue] = useState("");
+    const idRef = useRef<string>(null);
+    const refresh = useCallback(async () => {
+        try {
+            const res = await api.rawRequest<ArrayBuffer>({
+                url: `${site}index.php?s=captcha&?t=${Date.now()}`,
+                timeout: 15000,
+                responseType: 'arraybuffer'
+            });
+            idRef.current = res.headers['x-captcha-id'];
+            setUri(await bin2uri(res.data));
+
+        } catch (e) {
+            console.error(e);
+        }
+    }, []);
+
+    useEffect(() => { refresh() }, [])
+
+    return <Modal visible={visible} transparent>
+        <View className='w-full p-4 items-center justify-center  h-32'>
+            <View className="w-full h-24">
+                {uri ? <Image className="w-full h-12" source={{ uri }} /> : <View className="flex-1 h-12 bg-gray-200" />}
+                <Pressable className='w-8 h-8 m-2 justify-center items-center' onPress={refresh}><Icon name="reload" /></Pressable>
+            </View>
+            <View className="h-10 flex-row items-center rounded-2xl bg-surface-container-low p-0">
 
-    return <Modal closable visible={visible} animated animationType='slide-down'>
-        <View className='items-center w-24 h-24'>
-            <Image className="w-12 h-8" source={{uri: `${site}/index.php?s=/app_captcha&_t=`+t}} />
-            <TextInput
-                value={value}
-                onChangeText={setValue}
-                placeholder="请输上图中的字符"
-                placeholderTextColor="#9ca3af"
-                className="flex-1 p-0 text-xl font-medium text-center text-on-surface"
-            />
+                <TextInput
+                    value={value}
+                    onChangeText={setValue}
+                    keyboardType="phone-pad"
+                    maxLength={6}
+                    placeholder="请输入图中字符"
+                    placeholderTextColor="#9ca3af"
+                    className="flex-1 text-center  p-0 text-xl font-medium text-on-surface"
+                />
+            </View>
         </View>
+        <Button>确定</Button>
     </Modal>
 }

+ 41 - 18
src/utils/api.ts

@@ -1,25 +1,25 @@
 // import { getAccessToken } from '@/apis/auth';
-import axios from 'axios';
+import axios, { AxiosRequestConfig } from 'axios';
 import Constants from 'expo-constants';
 import { Platform } from 'react-native';
 import { type AccessToken } from './auth';
-const {api: apiConfig, jsVersion} = require('@/config.json') as AppConfig;
+const { api: apiConfig, jsVersion } = require('@/config.json') as AppConfig;
 let accessToken: AccessToken | undefined | null = null;
 
 export function setAccessToken(token?: AccessToken | null) {
-    
+
     accessToken = token;
 }
 
 export function getAccessToken() {
-  if (!accessToken) {
-    return null;
-  }
-  const now = Date.now() / 1000;
-  if (accessToken.expiresAt < now - 30) {
-    accessToken = null;
-  }
-  return accessToken;
+    if (!accessToken) {
+        return null;
+    }
+    const now = Date.now() / 1000;
+    if (accessToken.expiresAt < now - 30) {
+        accessToken = null;
+    }
+    return accessToken;
 }
 
 
@@ -60,7 +60,7 @@ export class HttpError {
 
 
 export class ApiError extends HttpError {
-    
+
 }
 
 
@@ -79,7 +79,7 @@ const verString = (() => {
 const apiClient = axios.create({
     baseURL: apiConfig.url,
     timeout: apiConfig.timeout || 10000,
-    
+
     headers: {
         "x-app-name": Constants.expoConfig?.slug || 'unknown',
         "x-app-version": verString,
@@ -90,7 +90,7 @@ const apiClient = axios.create({
     validateStatus: (status) => {
         return status > 199 && status < 500;
     },
-    
+
 });
 
 // 开发模式下输出请求和响应数据
@@ -166,13 +166,13 @@ async function request<T>(url: string, method: 'get' | 'post' | 'put' | 'delete'
             headers,
         });
         if (200 !== response.status) {
-            throw new HttpError(response.statusText  || 'http error', response.status);
+            throw new HttpError(response.statusText || 'http error', response.status);
         }
         const res = response.data;
-        
+
 
         if (`${res?.code}` !== '1') {
-            throw new ApiError(res?.message || response.statusText, res?.code||0, res?.data);
+            throw new ApiError(res?.message || response.statusText, res?.code || 0, res?.data);
         }
         return res?.data as T;
     } catch (error) {
@@ -184,6 +184,29 @@ async function request<T>(url: string, method: 'get' | 'post' | 'put' | 'delete'
         throw new HttpError(error?.message || (`${error}`) || "unknown error", 500);
     }
 }
+const rawRequest = async <T>(req: AxiosRequestConfig) => {
+
+    const headers: Record<string, string> = {
+        ...req.headers as any,
+        "x-app-name": Constants.expoConfig?.slug || 'unknown',
+        "x-app-version": verString,
+        "x-app-platform": Platform.OS,
+    };
+    let token = getAccessToken();
+    if (token?.token) {
+        headers['Authorization'] = `Bearer ${token.token}`;
+    }
+
+    const res = await axios.request<T>({
+        ...req,
+        timeout: apiConfig.timeout || 10000,
+        headers
+    });
+    if (res.status !== 200) {
+        throw new HttpError(res.statusText, res.status, res.data as string);
+    }
+    return res;
+}
 
 
 async function get<T>(api: string, params?: Record<string, any>): Promise<T> {
@@ -201,7 +224,7 @@ async function put<T>(api: string, data?: any): Promise<T> {
 async function deleted<T>(api: string): Promise<T> {
     return await request<T>(api, 'delete', {}, undefined);
 } const api = {
-    post,get,put,deleted
+    post, get, put, deleted, request, rawRequest
 }