lv před 1 měsícem
rodič
revize
90e10d55fd

+ 0 - 101
AGENTS.md

@@ -1,101 +0,0 @@
-# AGENTS.md
-
-本文件用于约束在 `Loan/client` 仓库内工作的 AI agent / 协作者,目标是让改动更稳、更贴近当前项目结构,并减少无关返工。仓库信息变化后,请同步更新此文件。
-
-## 项目概览
-
-- 项目名称:`Loan Assistant` / `借贷助手` 客户端
-- 技术栈:`Expo 55`、`React Native 0.83`、`React 19`、`TypeScript`、`expo-router`、`NativeWind 4`、`@ant-design/react-native 5`
-- 目标平台:`iOS`、`Android`、`Web`
-- 包管理器:仓库包含 `pnpm-lock.yaml`,默认优先使用 `pnpm`
-- 当前 UI 基调:浅色主题,根布局与全局 Provider 在 `src/app/_layout.tsx`
-
-## 常用命令
-
-- 安装依赖:`pnpm install`
-- 启动开发环境:`pnpm start`
-- 启动 Android:`pnpm android`
-- 启动 iOS:`pnpm ios`
-- 启动 Web:`pnpm web`
-- 代码检查:`pnpm lint`
-
-如果必须使用 `npm`,请确保不要混乱提交锁文件;默认仍以 `pnpm-lock.yaml` 为准。
-
-## 目录约定
-
-- `src/app`:`expo-router` 路由入口、`layout`、页面级 screen 文件
-- `src/app/(tabs)`:底部标签页相关路由
-- `src/components`:跨页面复用组件
-- `src/components/ui`:更基础的 UI 组件与页面积木
-- `src/hooks`:自定义 hooks
-- `src/utils`:基础能力与业务支撑工具,例如 `api`、`auth`、`storage`
-- `src/constants`:主题和常量
-- `src/global.css`:`NativeWind` 全局样式入口
-- `assets`:运行时资源
-- `design`:设计参考稿或静态参考资料,默认不作为运行时代码来源
-- `docs/antd`:本地 Ant Design RN 参考文档,新增或调整组件时优先查阅
-- `android`、`ios`:原生工程,除非是原生能力、权限、构建配置相关改动,否则不要随意修改
-- `dist`:构建产物目录,不要手工编辑
-
-## 代码组织规则
-
-- 路由文件尽量保持轻量,复杂 UI 和业务逻辑优先下沉到 `src/components`、`src/hooks`、`src/utils`
-- 本项目已配置路径别名:
-  - `@/*` -> `src/*`
-  - `@/assets/*` -> `assets/*`
-- 新页面优先放在 `src/app` 下对应路由位置,不要回退到旧式集中路由表
-- 涉及标签页结构时,同时检查 `src/app/(tabs)/_layout.tsx`
-- 平台差异优先通过 `*.web.tsx` 等平台文件处理,避免在单文件里堆过多平台分支
-- 尽量复用已有基础组件,不要在页面里重复造按钮、输入框、弹窗、状态标签
-
-## UI 与样式约定
-
-- 样式方案以 `NativeWind + global.css + tailwind.config.js` 为主
-- 组件型交互优先复用 `@ant-design/react-native`
-- 根节点已经包裹 `@ant-design/react-native` 的 `Provider`,全局 Provider 变更集中放在 `src/app/_layout.tsx`
-- 项目已有一套 Tailwind 扩展颜色和圆角 token,新增样式优先复用,不要到处散落魔法值
-- 如果引入新的 Ant Design RN 组件,先查 `docs/antd/llms-semantic.md` 或 `docs/antd/llms-full.txt`
-- Web 与 Native 表现如果需要分流,优先延续现有 `component.tsx` / `component.web.tsx` 结构
-
-## 数据、认证与存储
-
-- 网络请求优先复用 `src/utils/api.ts` 中的统一请求封装,不要重复创建新的 axios client
-- 接口地址与版本信息优先从现有配置读取,不要在页面或组件中硬编码 base URL
-- 鉴权上下文在 `src/utils/auth.tsx`,修改登录态逻辑时同步检查 Provider、hook、请求头注入
-- 本地持久化与缓存使用 `src/utils/storage.ts` 中的 `MMKV` 封装,避免绕过统一入口
-
-## 工程约定
-
-- TypeScript 处于 `strict` 模式,新增代码不要用 `any` 糊过去,必要时补充明确类型
-- `expo-router` 已启用 `typedRoutes`,新增路由时保持类型友好
-- 项目开启了 `reactCompiler`,默认不要为了“优化”到处添加 `useMemo` / `useCallback`
-- JS 层改动默认不要动 `android/`、`ios/`
-- 配置类文件如 `app.json`、`babel.config.js`、`metro.config.js`、`tailwind.config.js` 修改前先确认影响范围
-- 不要引入重量级新依赖,除非现有方案明显无法满足需求
-
-## Agent 工作方式
-
-- 开始前先看 `git status --short`,确认工作区是否已有用户未提交改动
-- 不要回滚与当前任务无关的变更
-- 先阅读相关文件,再改代码;避免“按印象重写”
-- 优先做最小必要改动,保持与现有风格一致
-- 修改公共能力时,检查调用方是否需要同步更新
-- 如果改动影响路由、鉴权、全局 Provider、主题或构建配置,在说明中明确指出
-
-## 验证要求
-
-- 最低要求:执行 `pnpm lint`
-- 如果改动影响页面交互、路由、平台差异或构建配置,尽量补充对应平台验证
-- 如果因为环境限制无法完成验证,需要在最终说明中明确写出“未验证项”
-
-## 提交说明建议
-
-最终汇报建议至少包含以下信息:
-
-- 改了什么
-- 为什么这么改
-- 怎么验证
-- 还有哪些风险或待补充项
-
-## 待项目负责人补充
-

+ 98 - 0
CLAUDE.md

@@ -0,0 +1,98 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+> 个人开发项目。优先帮我把活干完、踩过的坑别再踩,**不要**纠结协作规范、提交模板、验证清单、影响面汇报这些东西。
+
+## 项目概览
+
+- `Loan Assistant` / `借贷助手` 客户端
+- 技术栈:Expo 55、React Native 0.83、React 19、TypeScript(strict)、`expo-router`(typedRoutes + reactCompiler)、NativeWind 4、`@ant-design/react-native` 5
+- 目标平台:iOS、Android、Web(部署在 `/h5` 子路径下)
+- 包管理器:**pnpm**(仓库以 `pnpm-lock.yaml` 为准)
+
+## 命令
+- 使用 pnpm
+- `package.json` 已经列出常规命令。
+- 验证以 `pnpm lint` + 真机/模拟器为准。
+
+## 大图架构
+
+### 启动流程([src/app/_layout.tsx](src/app/_layout.tsx))
+`RootLayout` 渲染 `<Stack>` 之前按顺序:
+1. 加载 antd 图标字体(`useFonts({ antoutline })`)
+2. `POST common/check_version` 检查原生版本,必要时弹 Modal 引导跳商店/下载链接(`type: 'apk' | 'url'`,`force` 控制是否可取消)。最近一次检查时间存在 MMKV 的 `last_update_app`,15 分钟内不再重复
+3. `expo-updates` 拉热更新(`__DEV__` 下完全跳过)
+4. Provider 嵌套顺序固定:`ThemeProvider`(react-navigation) → antd `Provider`(theme=`antdTheme`) → `AuthProvider` → `Stack`
+
+新增全局 Provider 一律加在这里,不要散落到子页面。`Stack.screenOptions` 已设置了 iOS 风格透明模糊 header,新页面默认继承,特殊页(如 `sign-in`)通过 `Stack.Screen` 单独覆盖。
+
+### 路由([src/app/](src/app/))
+- `expo-router` + `experiments.typedRoutes`,新增页面文件即新增路由,不需要中央路由表
+- 底部 Tab 在 [src/app/(tabs)/](src/app/(tabs)/)(`index/customer/analytics/reports/profile`),改 Tab 结构同时改 `(tabs)/_layout.tsx`
+- 业务子流程目录:`credit/`、`customer/`;登录注册:`sign-in.tsx`、`sign-up.tsx`;Web 专属入口 `web.tsx` 和 `+html.tsx`
+- `app.json -> experiments.baseUrl: "/h5"`,Web 部署到 `/h5`,本地 dev server 也带这个前缀
+
+### 网络层([src/utils/api.ts](src/utils/api.ts))
+唯一的 axios client:
+- `baseURL` 来自 [src/config.json](src/config.json)(`api.url`),不要绕开它硬编码
+- 自动注入 header `x-app-name`、`x-app-version`(拼了 `app.json` version + 平台 versionCode/buildNumber + `config.json` 的 `jsVersion`)、`x-app-platform`
+- access token 是模块级单例(`setAccessToken` / `getAccessToken`),由 `AuthProvider` 在登录/启动时注入;过期判断带 30 秒余量,过期会自动清空
+- 响应约定:HTTP 200 + `data.code === "1"` 才算成功,其它一律抛 `ApiError`(`HttpError` 是父类,提供 `is4xx/is5xx` 等静态判断)
+- `validateStatus` 放行 200–499,5xx 才走 axios reject 路径
+- 文件上传走 `uploadFile`(用 `expo/fetch`,避开 RN 的 multipart 兼容问题)
+- `__DEV__` 下挂了请求/响应拦截器打 console;生产构建由 `metro.config.js` 的 `drop_console: true` 整体剔除
+
+### 鉴权([src/utils/auth.tsx](src/utils/auth.tsx))
+- `AuthProvider` 启动时从 MMKV `access_token` 读 token,调 `setAccessToken` 注入 api client
+- `signIn` / `smsSignIn` / `signUp` / `signOut` 调完接口后由调用方 `setToken(...)` 写回,Provider 同步落 MMKV 并切 `authStatus`
+- token 落盘用 `getGlobalStorage().setObject(key, value, ttl)`,TTL 用接口返回的 `expires_in`(秒)
+
+### 持久化([src/utils/storage.ts](src/utils/storage.ts))
+三个独立 MMKV 实例,路径都落在 `expo-file-system` 的 `cacheDirectory`:
+- `getGlobalStorage()` — 长期 KV(token、上次版本检查时间)
+- `getCaches()` — 业务通用缓存
+- `getApiCache()` — 接口结果缓存
+
+扩展方法 `setObject(key, value, ttlSeconds)` / `getObject(key)` 把 TTL 编进 JSON;后台每 15 分钟扫一遍清理过期项。Web 端有 [storage.web.ts](src/utils/storage.web.ts) 同名导出,靠 Metro 平台后缀分流。
+
+### 主题与样式
+- Tailwind 配置在 [tailwind.config.js](tailwind.config.js),扩展 token 来自仓库根的 [theme.js](theme.js)(注意是 `.js`,不在 `src/`)
+- antd 主题在 [src/constants/antd-theme.ts](src/constants/antd-theme.ts),react-navigation 的 `DefaultTheme` 在 `_layout.tsx` 基于它构造,三套主题共用同一组色板
+- [babel.config.js](babel.config.js) 启用 `babel-plugin-import` 给 antd-rn 做按需引入;`jsxImportSource: "nativewind"` 让 `className` 直接生效
+- 全局 CSS:[src/global.css](src/global.css)(NativeWind 入口,由 metro 注入)+ [src/global.web.css](src/global.web.css)(仅 Web)
+
+### 平台分流
+文件级用平台后缀:`*.web.tsx` 与 `*.tsx` 共存即可(如 `animated-icon.tsx` / `animated-icon.web.tsx`、`storage.ts` / `storage.web.ts`)。避免在单文件里堆过多 `Platform.OS` 分支。
+
+## 目录约定
+
+- `src/app/` — `expo-router` 路由 / layout / 页面级 screen
+- `src/components/` — 跨页面复用组件;`src/components/ui/` 放更基础的 UI 积木
+- `src/hooks/` — 自定义 hooks
+- `src/utils/` — `api`、`auth`、`storage`、`cache` 等基础能力
+- `src/constants/` — 主题与常量
+- `assets/` — 运行时资源
+- `docs/antd/` — 本地 antd-rn 参考文档(`llms-semantic.md` / `llms-full.txt` / `llms.txt`),新增/调整 antd 组件优先查阅
+- `design/` — 设计稿,**不**作为运行时代码来源
+- `android/`、`ios/` — 原生工程,JS 改动默认不动
+- `dist/` — 构建产物,不要手工编辑
+
+路径别名([tsconfig.json](tsconfig.json)):`@/*` → `src/*`,`@/assets/*` → `assets/*`。
+
+## 踩坑提醒
+
+- React 19 + `experiments.reactCompiler: true` 已开启,**别**为了"优化"到处补 `useMemo` / `useCallback`,编译器自己会处理
+- TypeScript strict,新代码别用 `any` 糊过去
+- 复用现有基础组件 / Tailwind token,别在页面里重复造按钮/输入框/弹窗/状态标签或散落魔法颜色值
+- `airpush.sh <runtimeVersion>` 发热更新时,`runtimeVersion` 必须等于已安装客户端的 `app.json -> expo.version`(policy 是 `appVersion`),否则设备拉不到
+- `pnpm reset-project` 会把 `app/` 移走,正常开发**绝对**别跑
+- 不要引入重量级新依赖,除非现有方案明显不够用
+- `app.json` / `babel.config.js` / `metro.config.js` / `tailwind.config.js` 改前先想清楚影响面
+
+## 仓库与外部上下文
+
+- 上一级目录 `Loan/` 下还有 `server/`(后端)、`expo-updates-server/`(热更新服务)、`ant-design-mobile-rn/`(被 fork 的 antd-rn)等同胞仓库;改接口/热更新协议可能需要联动它们,但默认不跨仓改
+- 接口 base URL `https://loan.ewaga.com/api/v1/`,热更新服务 `https://updates-loan.ewaga.com/`
+- iOS bundleId / Android package:`com.cdloan.assistant`,应用显示名"借贷助手" / "Loan Assistant"
+- 代码签名证书在 [code-signing/](code-signing/),`expo-updates` 用它校验热更新包,**别**提交私钥

+ 3 - 3
ios/Podfile.lock

@@ -2681,7 +2681,7 @@ SPEC CHECKSUMS:
   EXUpdates: 1457ed285cd7c5e9d4c388ace518af55fa4b21ec
   EXUpdatesInterface: 39d05400a9226c438565dbaaa8447b5f0672ba45
   FBLazyVector: 061f518bbd81677ed8a8317e2ae60b8779495808
-  hermes-engine: f4c4bf9a6979a9d777e0e4696e56be72362744d6
+  hermes-engine: e4ecc5957f671df3fa4828a33b435bd6955c25a5
   libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
   libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
   libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
@@ -2697,7 +2697,7 @@ SPEC CHECKSUMS:
   React: 3e14066ac707b3e369d09e2e923d8bee7f8c33ff
   React-callinvoker: bd7959a24564feaf5e4c8c11789e64884da13482
   React-Core: f060f4e14e9301685ce63ea65fbe903bf3397e45
-  React-Core-prebuilt: 0bd7bd67a89706f97a8d6150c409951862aecfc2
+  React-Core-prebuilt: e958d0ca3f8828f37f35c4cff2594b6e080fd991
   React-CoreModules: 89a1a544297be88ca4cdff317423f9e0d0c192a4
   React-cxxreact: 81b1bfa305b1b759cc0fe18327644816216e5993
   React-debug: 234fddb309822e13b9b442ef1bdca8d2b8c3cbf2
@@ -2760,7 +2760,7 @@ SPEC CHECKSUMS:
   ReactAppDependencyProvider: 2b19d66e5ddfe8dc7afb6338a4626156cbf2bab1
   ReactCodegen: 43f0948182edee9407c7b977fb059455dfeb9361
   ReactCommon: c6e81cc1ae185fa84863f3ea1d58caac4be741d7
-  ReactNativeDependencies: bcf3a9cabdc4c83653b9318342a4025f5a2effc1
+  ReactNativeDependencies: e3081feec6a8266d996ca8ce3d5707360258baf8
   RNGestureHandler: 0ea8153746a92b3744d4eaadade647debedf646e
   RNReanimated: 86e5991396f1aa514db90d6c79d4c3e37a37bb10
   RNScreens: 02e62f995ceb94ea465864b3bf4d4767b87f0362

+ 49 - 45
src/app/(tabs)/_layout.tsx

@@ -5,6 +5,47 @@ import { Image } from 'expo-image';
 import { router, Tabs } from 'expo-router';
 import React, { useEffect } from 'react';
 import { Platform, View } from 'react-native';
+
+const HOME_ICON = require('@/assets/images/tabIcons/home.png');
+const EXPLORE_ICON = require('@/assets/images/tabIcons/explore.png');
+const ICON_STYLE = { width: 24, height: 24 };
+
+function tinted(source: number) {
+  function TabIcon({ color }: { color: string }) {
+    return <Image style={ICON_STYLE} tintColor={color} source={source} />;
+  }
+  return TabIcon;
+}
+
+function plain(source: number) {
+  function TabIcon() {
+    return <Image style={ICON_STYLE} source={source} />;
+  }
+  return TabIcon;
+}
+
+function TabBarBackground() {
+  return Platform.OS === 'ios'
+    ? <BlurView tint="light" style={{ flex: 1 }} />
+    : <View style={{ flex: 1, backgroundColor: 'rgba(255, 255, 255, 0.9)' }} />;
+}
+
+const SCREEN_OPTIONS = {
+  headerShown: false,
+  tabBarActiveTintColor: Colors.tint,
+  tabBarStyle: { position: 'absolute' as const },
+  tabBarBackground: TabBarBackground,
+  lazy: true,
+};
+
+const TABS = [
+  { name: 'index', title: '首页', tabBarIcon: tinted(HOME_ICON) },
+  { name: 'customer', title: '客户', tabBarIcon: tinted(EXPLORE_ICON) },
+  { name: 'analytics', title: '分析', tabBarIcon: tinted(EXPLORE_ICON) },
+  { name: 'reports', title: '报告', tabBarIcon: tinted(EXPLORE_ICON) },
+  { name: 'profile', title: '我的', tabBarIcon: plain(EXPLORE_ICON) },
+];
+
 export default function TabLayout() {
   const { authStatus } = useAuth();
 
@@ -14,50 +55,13 @@ export default function TabLayout() {
     }
   }, [authStatus]);
 
+  if (authStatus !== 'auth') return null;
 
-  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>;
+  return (
+    <Tabs screenOptions={SCREEN_OPTIONS}>
+      {TABS.map(({ name, title, tabBarIcon }) => (
+        <Tabs.Screen key={name} name={name} options={{ title, tabBarIcon }} />
+      ))}
+    </Tabs>
+  );
 }

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

@@ -65,7 +65,7 @@ export default function AnalyticsScreen() {
           </Pressable>
         </View>
 
-        <UploadComponent onCompolete={() => { refresh() }} />
+        <UploadComponent onComplete={() => { refresh() }} />
 
 
 

+ 168 - 338
src/app/(tabs)/customer.tsx

@@ -5,7 +5,7 @@ import { ActivityIndicator, Toast } from '@ant-design/react-native';
 import { Ionicons } from '@expo/vector-icons';
 import { useFocusEffect } from 'expo-router';
 import React, { useCallback, useRef, useState } from 'react';
-import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
+import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
 import { SafeAreaView } from 'react-native-safe-area-context';
 
 type CustomerLoanStatus = 'matched' | 'unmatch' | 'pending' | 'completed' | undefined;
@@ -13,392 +13,222 @@ const CustomerLoanStatusText: Record<NonNullable<CustomerLoanStatus>, string> =
   matched: '已匹配',
   unmatch: '未匹配',
   pending: '匹配中',
-  completed: '已完成'
-}
+  completed: '已完成',
+};
 
 type Customer = {
   id: string;
   name: string;
   mobile: string;
   loan_status: CustomerLoanStatus;
-  note: string,
+  note: string;
   score?: string;
   updatetime: string;
 };
 
+const PAGE_SIZE = 15;
+const CACHE_KEY = 'customer_first';
 
+function CustomerCard({ item }: { item: Customer }) {
+  return (
+    <Pressable
+      className="rounded-2xl border border-outline-variant bg-surface-container-lowest px-4 py-4"
+      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>
 
-export default function CustomerScreens() {
+      <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>
+  );
+}
+
+const renderCustomer = ({ item }: { item: Customer }) => <CustomerCard item={item} />;
+const keyExtractor = (item: Customer) => item.id;
+
+export default function CustomerScreens() {
   const [searchKey, setSearchKey] = useState('');
   const [list, setList] = useState<Customer[]>([]);
-  const listRef = useRef<Customer[]>([]);
-  const startRef = useRef<number>(0);
+  const startRef = useRef(0);
   const loanStatusRef = useRef<CustomerLoanStatus>(undefined);
   const hasMoreRef = useRef(true);
+  const loadingRef = useRef(false);
   const [loading, setLoading] = useState(true);
+
   const load = useCallback(async (start: number, loanStatus?: CustomerLoanStatus) => {
+    if (loadingRef.current) return;
+    loadingRef.current = true;
     loanStatusRef.current = loanStatus;
     startRef.current = start;
     setLoading(true);
+
     if (start === 0 && !loanStatus) {
-      const cache = getApiCache().getObject<ListResponse<Customer>>("customer_first");
+      const cache = getApiCache().getObject<ListResponse<Customer>>(CACHE_KEY);
       if (cache) {
-        setLoading(false);
-        listRef.current = cache.list;
-        startRef.current += cache.list.length;
         setList(cache.list);
-        hasMoreRef.current = list.length > 14;
+        startRef.current = cache.list.length;
+        hasMoreRef.current = cache.list.length >= PAGE_SIZE;
+        setLoading(false);
+        loadingRef.current = false;
         return;
       }
     }
+
     try {
-      // @ts-ignore
       const res = await api.post<ListResponse<Customer>>('customer/list', {
         start,
-        size: 15,
-        loanStatus
+        size: PAGE_SIZE,
+        loanStatus,
       });
-      if (!res) {
-        throw "err";
-      }
-      const { list } = res;
-      if (list.length == 0) {
-        Toast.offline("没有更多数据可加载");
-        return;
+      if (loanStatusRef.current !== loanStatus) return;
+      const next = res?.list ?? [];
+      if (start === 0 && !loanStatus && next.length) {
+        getApiCache().setObject(CACHE_KEY, res, 60);
       }
-      if (start === 0 && !loanStatus) {
-        getApiCache().setObject('customer_first', res, 60);
+      hasMoreRef.current = next.length >= PAGE_SIZE;
+      setList((prev) => (start === 0 ? next : prev.concat(next)));
+      startRef.current = start + next.length;
+      if (!next.length && start > 0) {
+        Toast.offline('没有更多数据可加载');
       }
-      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 {
+    } catch {
+      Toast.fail('加载列表失败!');
+    } finally {
       setLoading(false);
+      loadingRef.current = false;
     }
   }, []);
 
   useFocusEffect(
     useCallback(() => {
       load(0);
-    }, []));
+    }, [load])
+  );
 
   const loadMore = useCallback(() => {
-    if (!hasMoreRef.current) {
-      return;
-    }
-    load(startRef.current);
-  }, []);
+    if (!hasMoreRef.current || loadingRef.current) return;
+    load(startRef.current, loanStatusRef.current);
+  }, [load]);
+
+  const ListHeader = (
+    <>
+      <Text className="mb-2 text-3xl font-extrabold tracking-tight text-on-surface">客户</Text>
+      <Text className="mb-5 text-base leading-7 text-on-surface-variant">
+        统一跟进客户资料、征信进度和产品匹配状态
+      </Text>
+
+      <View className="mb-3 flex-row items-center rounded-2xl bg-surface-container-low px-4 py-3">
+        <Ionicons name="search-outline" size={20} color="#737686" />
+        <TextInput
+          value={searchKey}
+          onChangeText={setSearchKey}
+          placeholder="搜索客户姓名 / 手机号"
+          placeholderTextColor="#9ca3af"
+          className="ml-3 flex-1 p-0 text-base text-on-surface"
+        />
+        {searchKey.length > 0 ? (
+          <Pressable hitSlop={8} onPress={() => setSearchKey('')}>
+            <Ionicons name="close-circle" size={20} color="#c3c6d7" />
+          </Pressable>
+        ) : null}
+      </View>
+
+      <View className="mb-5 flex-row gap-2">
+        {Object.entries(CustomerLoanStatusText).map(([key, item]) => (
+          <Pressable
+            key={key}
+            onPress={() => {}}
+            className="rounded-full bg-surface-container-lowest px-4 py-2"
+            style={({ pressed }) => ({ opacity: pressed ? 0.84 : 1 })}
+          >
+            <Text className="text-sm font-bold text-on-surface-variant">{item}</Text>
+          </Pressable>
+        ))}
+      </View>
+    </>
+  );
+
+  const ListEmpty = !loading ? (
+    <View className="items-center rounded-2xl border border-outline-variant bg-surface-container-lowest px-6 py-14">
+      <Ionicons name="people-outline" size={44} color="#c3c6d7" />
+      <Text className="mt-4 text-base text-on-surface-variant">暂无匹配客户</Text>
+    </View>
+  ) : null;
+
+  const ListFooter =
+    loading && list.length > 0 ? (
+      <View className="flex-row justify-center items-center mt-2">
+        <ActivityIndicator />
+        <Text className="ml-2 pb-4">加载中</Text>
+      </View>
+    ) : null;
+
+  const ListInitialLoading =
+    loading && list.length === 0 ? (
+      <View className="flex-row justify-center items-center mt-2">
+        <ActivityIndicator />
+        <Text className="ml-2 pb-4">加载中</Text>
+      </View>
+    ) : null;
+
   return (
     <SafeAreaView className="flex-1 bg-surface" edges={['top']}>
-      <ScrollView
+      <FlatList
         className="flex-1"
-        contentContainerClassName="px-5 pt-3 pb-24"
+        contentContainerClassName="px-5 pt-3 pb-24 gap-3"
+        data={list}
+        keyExtractor={keyExtractor}
+        renderItem={renderCustomer}
         keyboardShouldPersistTaps="handled"
         showsVerticalScrollIndicator={false}
-      >
-        <Text className="mb-2 text-3xl font-extrabold tracking-tight text-on-surface">
-          客户
-        </Text>
-        <Text className="mb-5 text-base leading-7 text-on-surface-variant">
-          统一跟进客户资料、征信进度和产品匹配状态
-        </Text>
-
-        <View className="mb-3 flex-row items-center rounded-2xl bg-surface-container-low px-4 py-3">
-          <Ionicons name="search-outline" size={20} color="#737686" />
-          <TextInput
-            value={searchKey}
-            onChangeText={setSearchKey}
-            placeholder="搜索客户姓名 / 手机号"
-            placeholderTextColor="#9ca3af"
-            className="ml-3 flex-1 p-0 text-base text-on-surface"
-          />
-          {!loading && searchKey.length > 0 ? (
-            <Pressable hitSlop={8} onPress={() => setSearchKey('')}>
-              <Ionicons name="close-circle" size={20} color="#c3c6d7" />
-            </Pressable>
-          ) : null}
-
-        </View>
-
-        <ScrollView
-          horizontal
-          showsHorizontalScrollIndicator={false}
-          contentContainerClassName="gap-2 pb-1"
-          className="mb-5"
-        >
-          {Object.entries(CustomerLoanStatusText).map(([key, item]) => {
-            const active = false;
-            return (
-              <Pressable
-                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'
-                    }`}
-                >
-                  {item}
-                </Text>
-              </Pressable>
-            );
-          })}
-        </ScrollView>
-
-        <View className="gap-3">
-          {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>
-          ) : (
-            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>
-        {loading && startRef.current > 0 && <View className='flex-row justify-center items-center mt-2'>
-          <ActivityIndicator /><Text className='ml-2 pb-4'>加载中</Text>
-        </View>}
-      </ScrollView>
+        ListHeaderComponent={ListHeader}
+        ListEmptyComponent={ListInitialLoading ?? ListEmpty}
+        ListFooterComponent={ListFooter}
+        onEndReached={loadMore}
+        onEndReachedThreshold={0.5}
+      />
 
       <View className="absolute bottom-7 right-5">
         <Pressable

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

@@ -1,6 +1,6 @@
 import { SectionHeader } from '@/components/ui/section-header';
 import { StatusBadge } from '@/components/ui/status-badge';
-import UIButton from '@/components/ui/UIButton';
+import UIButton from '@/components/ui/ui-button';
 import api from '@/utils/api';
 import { useSWC } from '@/utils/cache';
 import { Ionicons } from '@expo/vector-icons';

+ 3 - 3
src/app/_layout.tsx

@@ -39,7 +39,7 @@ export const DefaultTheme: Theme = {
 
 
 export default function RootLayout() {
-  const [initing, setIniting] = useState(true);
+  const [initializing, setIniting] = useState(true);
 
 
   // const colorScheme = useColorScheme();
@@ -177,7 +177,7 @@ export default function RootLayout() {
   return (
     <ThemeProvider value={DefaultTheme}>
       <Provider theme={antdTheme}>
-        {(!initing && fontsLoaded) &&
+        {(!initializing && fontsLoaded) &&
           <AuthProvider>
             <AnimatedSplashOverlay />
             <Stack screenOptions={{
@@ -194,7 +194,7 @@ export default function RootLayout() {
             </Stack>
           </AuthProvider>}
       </Provider>
-      {initing && <View className='absolute left-0 top-0 right-0 bottom-0 bg-[#f6f6f6] z-50'>
+      {initializing && <View className='absolute left-0 top-0 right-0 bottom-0 bg-[#f6f6f6] z-50'>
         <Image contentFit='contain' style={{ flex: 1 }} source={require('@/assets/images/uploading.jpg')} />
       </View>}
     </ThemeProvider>

+ 3 - 3
src/app/customer/add.tsx

@@ -1,4 +1,4 @@
-import UIButton from "@/components/ui/UIButton";
+import UIButton from "@/components/ui/ui-button";
 import { Colors } from "@/constants/theme";
 import api from "@/utils/api";
 import { DatePicker, Input, Modal, Radio, Toast } from "@ant-design/react-native";
@@ -391,7 +391,7 @@ export default function AddCustomerScreen() {
   const savedRef = useRef<boolean>(false);
 
 
-  const onSelectCredis = (isCancel: boolean) => {
+  const onSelectCredit = (isCancel: boolean) => {
     setSelectCredit(false);
     // if (isCancel) {
     //   return;
@@ -694,7 +694,7 @@ export default function AddCustomerScreen() {
           </UIButton>
         </ScrollView>
       </KeyboardAvoidingView>
-      <UploadScreen visible={selectCredit} onClose={onSelectCredis} />
+      <UploadScreen visible={selectCredit} onClose={onSelectCredit} />
     </View>
   );
 }

+ 8 - 9
src/app/sign-in.tsx

@@ -32,7 +32,7 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
 export default function SignInScreen() {
   const [authMode, setAuthMode] = useState<'sms' | 'password'>('sms');
   const [agreed, setAgreed] = useState(false);
-  const [flushAgree, setFlushAgree] = useState(false);
+  const [agreementHighlight, setFlushAgree] = useState(false);
   const [loading, setLoading] = useState(false);
   const [mobile, setMobile] = useState('');
   const [code, setCode] = useState('');
@@ -49,12 +49,11 @@ export default function SignInScreen() {
   const waitCaptcha = useRef<(res: CaptchaRes) => void | null>(null);
   const needsCaptchaRef = useRef(false);
   const handleCaptcha = (res: CaptchaRes) => {
-
-    let fun = waitCaptcha.current;
+    const resolver = waitCaptcha.current;
     waitCaptcha.current = null;
     setCaptchaVisible(false);
-    fun?.(res);
-  }
+    resolver?.(res);
+  };
   const handleSendCode = async () => {
     if (mobile.trim().length !== 11) {
       Toast.fail('请先输入 11 位手机号');
@@ -75,7 +74,7 @@ export default function SignInScreen() {
         const res = await api.post<{
           needsCaptcha?: boolean;
           timerout: number;
-        }>("sms/send?__session_id=" + captcha?.sid || '', {
+        }>(`sms/send?__session_id=${captcha?.sid ?? ''}`, {
           mobile: mobile,
           event: 'login',
           captcha_code: captcha?.code,
@@ -85,7 +84,7 @@ export default function SignInScreen() {
           needsCaptchaRef.current = true;
           continue;
         }
-        setSmsTtl(res.timerout);
+        setSmsTtl(res.timerout); // 后端字段拼写为 timerout,保留以兼容接口
         break;
 
       } catch (e) {
@@ -107,7 +106,7 @@ export default function SignInScreen() {
 
 
   useInterval(() => {
-    setSmsTtl((pre) => pre - 1)
+    setSmsTtl((prev) => prev - 1)
   }, smsTtl > 0 ? 1000 : null);
 
 
@@ -330,7 +329,7 @@ export default function SignInScreen() {
                 ))}
               </View>
 
-              <View className={`flex-row items-start gap-3 px-2 rounded-md border-2 transition-colors duration-700 ${flushAgree ? ' border-primary/50' : 'border-transparent'}`}>
+              <View className={`flex-row items-start gap-3 px-2 rounded-md border-2 transition-colors duration-700 ${agreementHighlight ? ' border-primary/50' : 'border-transparent'}`}>
                 <Pressable
                   onPress={() => setAgreed((value) => !value)}
                   hitSlop={8}

+ 11 - 12
src/app/sign-up.tsx

@@ -32,12 +32,12 @@ export default function SignUpScreen() {
   const [mobile, setMobile] = useState('');
   const [code, setCode] = useState('');
   const [password, setPassword] = useState('');
-  const [rePass, setRepass] = useState('');
+  const [confirmPassword, setConfirmPassword] = useState('');
   const [email, setEmail] = useState('');
   const [name, setName] = useState('');
   const [organization, setOrganization] = useState('');
   const [agreed, setAgreed] = useState(false);
-  const [flushAgree, setFlushAgree] = useState(false);
+  const [agreementHighlight, setFlushAgree] = useState(false);
   const [loading, setLoading] = useState(false);
   const scrollView = useRef<ScrollView>(null);
   const [captchaVisible, setCaptchaVisible] = useState<boolean>(false);
@@ -49,12 +49,11 @@ export default function SignUpScreen() {
   const waitCaptcha = useRef<(res: CaptchaRes) => void | null>(null);
   const needsCaptchaRef = useRef(false);
   const handleCaptcha = (res: CaptchaRes) => {
-
-    let fun = waitCaptcha.current;
+    const resolver = waitCaptcha.current;
     waitCaptcha.current = null;
     setCaptchaVisible(false);
-    fun?.(res);
-  }
+    resolver?.(res);
+  };
   const handleSendCode = async () => {
     if (mobile.trim().length !== 11) {
       Toast.fail('请先输入 11 位手机号');
@@ -75,7 +74,7 @@ export default function SignUpScreen() {
         const res = await api.post<{
           needsCaptcha?: boolean;
           timerout: number;
-        }>("sms/send?__session_id=" + captcha?.sid || '', {
+        }>(`sms/send?__session_id=${captcha?.sid ?? ''}`, {
           mobile: mobile,
           event: 'register',
           captcha_code: captcha?.code,
@@ -107,7 +106,7 @@ export default function SignUpScreen() {
 
 
   useInterval(() => {
-    setSmsTtl((pre) => pre - 1)
+    setSmsTtl((prev) => prev - 1)
   }, smsTtl > 0 ? 1000 : null);
 
   const handleRegister = async () => {
@@ -128,7 +127,7 @@ export default function SignUpScreen() {
       Toast.fail('请输入不少于 6 位的登录密码');
       return;
     }
-    if (rePass !== password) {
+    if (confirmPassword !== password) {
       Toast.fail("两次密码输入不一至");
       return;
     }
@@ -277,8 +276,8 @@ export default function SignUpScreen() {
                   <FieldLabel>重复密码</FieldLabel>
                   <View className="mb-5 flex-row items-center rounded-2xl bg-surface-container-low px-5 py-4">
                     <TextInput
-                      value={rePass}
-                      onChangeText={setRepass}
+                      value={confirmPassword}
+                      onChangeText={setConfirmPassword}
                       editable={!loading}
                       secureTextEntry
                       maxLength={20}
@@ -352,7 +351,7 @@ export default function SignUpScreen() {
               </View>
             </View>
 
-            <View className={`flex-row items-start gap-3 px-2 rounded-md border-2 transition-colors duration-700 ${flushAgree ? ' border-primary/50' : 'border-transparent'}`}>
+            <View className={`flex-row items-start gap-3 px-2 rounded-md border-2 transition-colors duration-700 ${agreementHighlight ? ' border-primary/50' : 'border-transparent'}`}>
               <Pressable
                 onPress={() => setAgreed((value) => !value)}
                 hitSlop={8}

+ 0 - 6
src/components/animated-icon.module.css

@@ -1,6 +0,0 @@
-.expoLogoBackground {
-  background-image: linear-gradient(180deg, #3c9ffe, #0274df);
-  border-radius: 40px;
-  width: 128px;
-  height: 128px;
-}

+ 8 - 100
src/components/animated-icon.tsx

@@ -1,36 +1,23 @@
-import { Image } from 'expo-image';
 import { useState } from 'react';
-import { Dimensions, StyleSheet, View } from 'react-native';
+import { Dimensions, StyleSheet } from 'react-native';
 import Animated, { Easing, Keyframe } from 'react-native-reanimated';
 import { scheduleOnRN } from 'react-native-worklets';
 
 const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
 const DURATION = 600;
 
+const splashKeyframe = new Keyframe({
+  0: { transform: [{ scale: INITIAL_SCALE_FACTOR }], opacity: 1 },
+  20: { opacity: 1 },
+  70: { opacity: 0, easing: Easing.elastic(0.7) },
+  100: { opacity: 0, transform: [{ scale: 1 }], easing: Easing.elastic(0.7) },
+});
+
 export function AnimatedSplashOverlay() {
   const [visible, setVisible] = useState(true);
 
   if (!visible) return null;
 
-  const splashKeyframe = new Keyframe({
-    0: {
-      transform: [{ scale: INITIAL_SCALE_FACTOR }],
-      opacity: 1,
-    },
-    20: {
-      opacity: 1,
-    },
-    70: {
-      opacity: 0,
-      easing: Easing.elastic(0.7),
-    },
-    100: {
-      opacity: 0,
-      transform: [{ scale: 1 }],
-      easing: Easing.elastic(0.7),
-    },
-  });
-
   return (
     <Animated.View
       entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
@@ -44,86 +31,7 @@ export function AnimatedSplashOverlay() {
   );
 }
 
-const keyframe = new Keyframe({
-  0: {
-    transform: [{ scale: INITIAL_SCALE_FACTOR }],
-  },
-  100: {
-    transform: [{ scale: 1 }],
-    easing: Easing.elastic(0.7),
-  },
-});
-
-const logoKeyframe = new Keyframe({
-  0: {
-    transform: [{ scale: 1.3 }],
-    opacity: 0,
-  },
-  40: {
-    transform: [{ scale: 1.3 }],
-    opacity: 0,
-    easing: Easing.elastic(0.7),
-  },
-  100: {
-    opacity: 1,
-    transform: [{ scale: 1 }],
-    easing: Easing.elastic(0.7),
-  },
-});
-
-const glowKeyframe = new Keyframe({
-  0: {
-    transform: [{ rotateZ: '0deg' }],
-  },
-  100: {
-    transform: [{ rotateZ: '7200deg' }],
-  },
-});
-
-export function AnimatedIcon() {
-  return (
-    <View style={styles.iconContainer}>
-      <Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
-        <Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
-      </Animated.View>
-
-      <Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
-      <Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
-        <Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
-      </Animated.View>
-    </View>
-  );
-}
-
 const styles = StyleSheet.create({
-  imageContainer: {
-    justifyContent: 'center',
-    alignItems: 'center',
-  },
-  glow: {
-    width: 201,
-    height: 201,
-    position: 'absolute',
-  },
-  iconContainer: {
-    justifyContent: 'center',
-    alignItems: 'center',
-    width: 128,
-    height: 128,
-    zIndex: 100,
-  },
-  image: {
-    position: 'absolute',
-    width: 76,
-    height: 71,
-  },
-  background: {
-    borderRadius: 40,
-    experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
-    width: 128,
-    height: 128,
-    position: 'absolute',
-  },
   backgroundSolidColor: {
     ...StyleSheet.absoluteFill,
     backgroundColor: '#208AEF',

+ 0 - 105
src/components/animated-icon.web.tsx

@@ -1,108 +1,3 @@
-import { Image } from 'expo-image';
-import { StyleSheet, View } from 'react-native';
-import Animated, { Keyframe, Easing } from 'react-native-reanimated';
-
-import classes from './animated-icon.module.css';
-const DURATION = 300;
-
 export function AnimatedSplashOverlay() {
   return null;
 }
-
-const keyframe = new Keyframe({
-  0: {
-    transform: [{ scale: 0 }],
-  },
-  60: {
-    transform: [{ scale: 1.2 }],
-    easing: Easing.elastic(1.2),
-  },
-  100: {
-    transform: [{ scale: 1 }],
-    easing: Easing.elastic(1.2),
-  },
-});
-
-const logoKeyframe = new Keyframe({
-  0: {
-    opacity: 0,
-  },
-  60: {
-    transform: [{ scale: 1.2 }],
-    opacity: 0,
-    easing: Easing.elastic(1.2),
-  },
-  100: {
-    transform: [{ scale: 1 }],
-    opacity: 1,
-    easing: Easing.elastic(1.2),
-  },
-});
-
-const glowKeyframe = new Keyframe({
-  0: {
-    transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
-    opacity: 0,
-  },
-  [DURATION / 1000]: {
-    transform: [{ rotateZ: '0deg' }, { scale: 1 }],
-    opacity: 1,
-    easing: Easing.elastic(0.7),
-  },
-  100: {
-    transform: [{ rotateZ: '7200deg' }],
-  },
-});
-
-export function AnimatedIcon() {
-  return (
-    <View style={styles.iconContainer}>
-      <Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
-        <Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
-      </Animated.View>
-
-      <Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
-        <div className={classes.expoLogoBackground} />
-      </Animated.View>
-
-      <Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
-        <Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
-      </Animated.View>
-    </View>
-  );
-}
-
-const styles = StyleSheet.create({
-  container: {
-    alignItems: 'center',
-    width: '100%',
-    zIndex: 1000,
-    position: 'absolute',
-    top: 128 / 2 + 138,
-  },
-  imageContainer: {
-    justifyContent: 'center',
-    alignItems: 'center',
-  },
-  glow: {
-    width: 201,
-    height: 201,
-    position: 'absolute',
-  },
-  iconContainer: {
-    justifyContent: 'center',
-    alignItems: 'center',
-    width: 128,
-    height: 128,
-  },
-  image: {
-    position: 'absolute',
-    width: 76,
-    height: 71,
-  },
-  background: {
-    width: 128,
-    height: 128,
-    position: 'absolute',
-  },
-});

+ 87 - 74
src/components/captcha-box.tsx

@@ -1,105 +1,118 @@
 import { api as apiCfg } from '@/config.json';
 import { Colors } from '@/constants/theme';
 import api from '@/utils/api';
-import { Icon, Modal } from "@ant-design/react-native";
+import { Icon, Modal } from '@ant-design/react-native';
 import { Image } from 'expo-image';
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { Pressable, TextInput, View } from 'react-native';
 
-function bin2uri(data: Blob) {
+function blobToDataUrl(blob: Blob) {
     return new Promise<string>((resolve, reject) => {
-      
         const reader = new FileReader();
-        reader.onloadend = () => {
-            resolve(reader.result as string);
-        };
+        reader.onloadend = () => resolve(reader.result as string);
         reader.onerror = reject;
-        reader.readAsDataURL(data);
+        reader.readAsDataURL(blob);
     });
 }
-export 
-interface CaptchaRes {
+
+export interface CaptchaRes {
     ok: boolean;
     code?: string;
     sid?: string;
 }
 
-export default function CaptchaBox({ onClose, visible }: { onClose: (res: CaptchaRes) => void; visible: boolean }) {
-    const [v, setVisible] = useState<boolean>(false);
-    const [uri, setUri] = useState<string>(null!);
-    const [value, setValue] = useState("");
-    const idRef = useRef<string|undefined>(null);
-    const resRef = useRef<CaptchaRes>({ok: false});
+export default function CaptchaBox({
+    onClose,
+    visible,
+}: {
+    onClose: (res: CaptchaRes) => void;
+    visible: boolean;
+}) {
+    const [internalVisible, setInternalVisible] = useState(false);
+    const [imageUri, setImageUri] = useState<string>(null!);
+    const [code, setCode] = useState('');
+    const sessionIdRef = useRef<string | undefined>(undefined);
+    const resultRef = useRef<CaptchaRes>({ ok: false });
 
-      const refresh = useCallback(async () => {
+    const refresh = useCallback(async () => {
         try {
             const res = await api.rawRequest<Blob>({
-                url: `${apiCfg.url}common/captcha&?t=${Date.now()}`,
+                url: `${apiCfg.url}common/captcha?t=${Date.now()}`,
                 timeout: 15000,
-                responseType: 'blob'
+                responseType: 'blob',
             });
-            idRef.current = res.headers['x-session_id'] as string;
-            setUri(await bin2uri(res.data));
-
+            sessionIdRef.current = res.headers['x-session_id'] as string;
+            setImageUri(await blobToDataUrl(res.data));
         } catch (e) {
             console.error(e);
         }
     }, []);
-    // alert(v)
-    useEffect(()=> {
-        setVisible(visible);
-        setUri(null!);
+
+    useEffect(() => {
+        setInternalVisible(visible);
+        setImageUri(null!);
         if (visible) {
             refresh();
-            setValue('');
+            setCode('');
         }
     }, [refresh, visible]);
-  
-
 
-    return <Modal visible={v} transparent style={{width: 208}} onClose={()=>onClose(resRef.current)}
-    
-    footer={[
-        {
-            text: '取消',
-            style: {color: Colors.textSecondary},
-            onPress: ()=> {
-                resRef.current!.ok = false;
-                setVisible(false);
-            }
-        },
-        {
-            text: '确认',
-            onPress: ()=> {
-                // if (value.length !== 6) {
-                //     Toast.fail("请输入 6 位字符");
-                //     return;
-                // }
-                resRef.current!.ok = true;
-                resRef.current.code = value;
-                resRef.current.sid = idRef.current!;
-                setVisible(false);   
-            }
-        }
-    ]}
-    >
-        <View className='w-48 items-center justify-center'>
-            <View className="w-full">
-                {uri ? <Image style={{width: 175, height: 44}} className="w-full h-12 bg-gray-500" source={{ uri }} /> : <View className="w-full h-12 bg-gray-200" />}
-                <Pressable className='w-8 h-8 p-1 justify-center items-center' onPress={refresh}><Icon name="reload" /></Pressable>
-            </View>
-            <View className="h-8 flex-row items-center rounded-2xl bg-surface-container-low p-0">
-
-                <TextInput
-                    value={value}
-                    onChangeText={setValue}
-                    keyboardType="phone-pad"
-                    maxLength={6}
-                    placeholder="请输入图中字符"
-                    placeholderTextColor="#9ca3af"
-                    className="flex-1 h-8 text-center p-0 text-sm font-medium text-on-surface"
-                />
+    return (
+        <Modal
+            visible={internalVisible}
+            transparent
+            style={{ width: 208 }}
+            onClose={() => onClose(resultRef.current)}
+            footer={[
+                {
+                    text: '取消',
+                    style: { color: Colors.textSecondary },
+                    onPress: () => {
+                        resultRef.current.ok = false;
+                        setInternalVisible(false);
+                    },
+                },
+                {
+                    text: '确认',
+                    onPress: () => {
+                        resultRef.current.ok = true;
+                        resultRef.current.code = code;
+                        resultRef.current.sid = sessionIdRef.current;
+                        setInternalVisible(false);
+                    },
+                },
+            ]}
+        >
+            <View className="w-48 items-center justify-center">
+                <View className="w-full">
+                    {imageUri ? (
+                        <Image
+                            style={{ width: 175, height: 44 }}
+                            className="w-full h-12 bg-gray-500"
+                            source={{ uri: imageUri }}
+                        />
+                    ) : (
+                        <View className="w-full h-12 bg-gray-200" />
+                    )}
+                    <Pressable
+                        className="w-8 h-8 p-1 justify-center items-center"
+                        onPress={refresh}
+                    >
+                        <Icon name="reload" />
+                    </Pressable>
+                </View>
+                <View className="h-8 flex-row items-center rounded-2xl bg-surface-container-low p-0">
+                    <TextInput
+                        value={code}
+                        onChangeText={setCode}
+                        keyboardType="phone-pad"
+                        maxLength={6}
+                        placeholder="请输入图中字符"
+                        placeholderTextColor="#9ca3af"
+                        className="flex-1 h-8 text-center p-0 text-sm font-medium text-on-surface"
+                    />
+                </View>
             </View>
-        </View>
-    </Modal>
-}
+        </Modal>
+    );
+}

+ 0 - 93
src/components/ui/UIButton.tsx

@@ -1,93 +0,0 @@
-import { Colors } from '@/constants/theme';
-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, { 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?: () => PromiseLike<unknown> | unknown;
-    disabled?: boolean;
-    className?: string;
-    textClassName?: string;
-    href?: Href
-    type?: 'primary' | 'second' | 'link'
-    icon?: IconNames | React.ReactNode;
-    style?: ViewStyle;
-    loading?: boolean;
-}
-
-function ButtonTextChild({ type, disabled, textClassName, children, loading }: UIButtonProps) {
-
-    return loading ? <View className='flex-row justify-center items-center'>
-        <ActivityIndicator color={disabled ? '#888' : Colors['on-primary']['DEFAULT']} />
-        <Text className={clsx(
-            textClassName,
-            'text-sm font-semibold', {
-            '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-sm font-semibold', {
-        '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>
-}
-
-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);
-            res.finally(() => {
-                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, {
-            'bg-primary border-primary': type === 'primary',
-            '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': isDisabled && type === 'primary',
-            'bg-gray-300 border-gray-300': isDisabled && !type,
-            'bg-bg-gray-400 border-gray-200': isDisabled && type === 'second'
-        })}
-        disabled={isDisabled}
-        onPress={handlePress}
-        style={style}>
-        {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}
-        </Link>
-    }
-
-    return inner;
-}

+ 139 - 0
src/components/ui/ui-button.tsx

@@ -0,0 +1,139 @@
+import { Colors } from '@/constants/theme';
+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, { useEffect, useState } from 'react';
+import { Pressable, Text, View, ViewStyle } from 'react-native';
+
+interface UIButtonProps {
+    children?: string | React.ReactNode;
+    title?: string | React.ReactNode;
+    onPress?: () => PromiseLike<unknown> | unknown;
+    disabled?: boolean;
+    className?: string;
+    textClassName?: string;
+    href?: Href;
+    type?: 'primary' | 'second' | 'link';
+    icon?: IconNames | React.ReactNode;
+    style?: ViewStyle;
+    loading?: boolean;
+}
+
+type ButtonType = UIButtonProps['type'];
+
+function getIconColor(type: ButtonType, disabled: boolean) {
+    if (disabled) return Colors['on-surface']['variant'];
+    if (type === 'primary') return '#fff';
+    if (type === 'second') return Colors.secondary.DEFAULT;
+    return Colors.primary.DEFAULT;
+}
+
+function ButtonLabel({
+    type,
+    disabled,
+    textClassName,
+    children,
+    loading,
+}: {
+    type: ButtonType;
+    disabled: boolean;
+    textClassName?: string;
+    children: React.ReactNode;
+    loading: boolean;
+}) {
+    const labelClass = clsx(textClassName, 'text-sm font-semibold', {
+        'text-on-primary': !disabled && type === 'primary',
+        'text-primary': !disabled && type !== 'primary' && type !== 'second',
+        'text-secondary': !disabled && type === 'second',
+        'text-on-surface-variant/65 font-normal': disabled,
+    });
+
+    if (!loading) return <Text className={labelClass}>{children}</Text>;
+    return (
+        <View className="flex-row justify-center items-center">
+            <ActivityIndicator color={disabled ? '#888' : Colors['on-primary']['DEFAULT']} />
+            <Text className={labelClass}>{children}</Text>
+        </View>
+    );
+}
+
+export default function UIButton({
+    onPress,
+    href,
+    type,
+    icon,
+    className,
+    style,
+    disabled,
+    children,
+    title,
+    textClassName,
+    loading,
+}: UIButtonProps) {
+    const [busy, setBusy] = useState(loading);
+    const isDisabled = Boolean(disabled || busy);
+
+    useEffect(() => {
+        setBusy(loading);
+    }, [loading]);
+
+    const label = children ?? title;
+
+    const handlePress = () => {
+        if (isDisabled) return;
+        const res = onPress?.();
+        if (isPromise(res)) {
+            setBusy(true);
+            (res as Promise<unknown>).finally(() => setBusy(false));
+        }
+    };
+
+    const inner = (
+        <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,
+                {
+                    'bg-primary border-primary': type === 'primary',
+                    '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': isDisabled && type === 'primary',
+                    'bg-gray-300 border-gray-300': isDisabled && !type,
+                    'bg-gray-400 border-gray-200': isDisabled && type === 'second',
+                }
+            )}
+            disabled={isDisabled}
+            onPress={handlePress}
+            style={style}
+        >
+            {typeof icon === 'string' ? (
+                <Icon
+                    name={icon as IconNames}
+                    size={20}
+                    style={{ marginRight: 4, color: getIconColor(type, isDisabled) }}
+                />
+            ) : (
+                icon
+            )}
+            {typeof label === 'string' ? (
+                <ButtonLabel type={type} disabled={isDisabled} loading={!!busy} textClassName={textClassName}>
+                    {label}
+                </ButtonLabel>
+            ) : (
+                label
+            )}
+        </Pressable>
+    );
+
+    if (typeof href !== 'undefined') {
+        return (
+            <Link href={href} asChild>
+                {inner}
+            </Link>
+        );
+    }
+    return inner;
+}

+ 198 - 243
src/components/upload.tsx

@@ -1,312 +1,267 @@
-
-import UIButton from "@/components/ui/UIButton";
-import { Colors } from "@/constants/theme";
-import { ActionSheet, ActivityIndicator, Icon, Modal, Toast } from "@ant-design/react-native";
+import UIButton from '@/components/ui/ui-button';
+import { Colors } from '@/constants/theme';
+import api from '@/utils/api';
+import { openSystemSettings } from '@/utils/os';
+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 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';
-import { Link } from "expo-router";
-import { useCallback, useEffect, useState } from "react";
-
-
+import {
+    ImagePickerAsset,
+    launchCameraAsync,
+    launchImageLibraryAsync,
+    requestCameraPermissionsAsync,
+    requestMediaLibraryPermissionsAsync,
+} from 'expo-image-picker';
+import { Link } from 'expo-router';
+import { useCallback, useEffect, useState } from 'react';
+import { Platform, Pressable, Text, View } from 'react-native';
+
+type Asset = ImagePickerAsset | DocumentPickerAsset;
+type UploadHandler = (assets: Asset[]) => void;
+
+type UploadState = 0 | 1 | 2; // 0: 待选择 / 1: 上传中 / 2: 已提交
+
+type Attachment = { url: string; fullurl: string; attid: unknown };
+
+const DOCUMENT_MIME_TYPES = [
+    'application/pdf',
+    'application/msword',
+    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'application/vnd.ms-excel',
+    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    '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',
+    'text/html',
+    'image/*',
+];
+
+function alertPermissionDenied() {
+    Modal.alert(
+        '请允许相册权限',
+        '如果点击"确认"按钮后没有跳转,请自己前往系统设置开启相关权限',
+        [{ text: '确认', onPress: openSystemSettings }]
+    );
+}
 
-const takePhoto = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
+const takePhoto = async (upload: UploadHandler) => {
     const permission = await requestCameraPermissionsAsync();
-
     if (!permission.granted) {
-        Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
-            {
-                text: "确认",
-                onPress: openSystemSettings,
-            }
-        ]);
-        // 跳转到 ios/android 相关设置页面
+        alertPermissionDenied();
         return;
     }
-    const l = Toast.loading('正在打开相机...');
+    const toastKey = Toast.loading('正在打开相机...');
     try {
-        const result = await launchCameraAsync({
-            allowsEditing: false, // 是否允许裁剪
-            quality: 0.9,        // 照片质量
-        });
-
-        if (!result.canceled) {
-            upload(result.assets);
-        }
+        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(toastKey);
     }
-    finally {
-        Toast.remove(l);
-    }
-}
+};
 
-const picImg = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
+const pickImage = async (upload: UploadHandler) => {
     const permission = await requestMediaLibraryPermissionsAsync();
-
     if (!permission.granted) {
-        Modal.alert("请允许相册权限", "如果点击“确认“按钮后没有跳转,请自己前往系统设置开启相关权限", [
-            {
-                text: "确认",
-                onPress: openSystemSettings,
-            }
-        ]);
-        // 跳转到 ios/android 相关设置页面
+        alertPermissionDenied();
         return;
     }
-    const l = Toast.loading('正在打开相册...');
+    const toastKey = Toast.loading('正在打开相册...');
     try {
-        let result = await launchImageLibraryAsync({
-            mediaTypes: ['images'], // 只选图片
+        const result = await launchImageLibraryAsync({
+            mediaTypes: ['images'],
             allowsEditing: false,
-            quality: .9, // 质量 0~1
+            quality: 0.9,
         });
-
-        if (!result.canceled) {
-            upload(result.assets);
-        }
+        if (!result.canceled) upload(result.assets);
     } catch (err) {
         console.warn(err);
         Toast.fail('打开相册失败,请重试');
     } finally {
-
-        Toast.remove(l);
+        Toast.remove(toastKey);
     }
-}
-
-const pickDoc = async (upload: (assets: ImagePickerAsset[] | DocumentPickerAsset[]) => void) => {
-    const l = Toast.loading('正在打开相册...');
+};
 
+const pickDocument = async (upload: UploadHandler) => {
+    const toastKey = 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, // 复制到应用缓存目录
+        const result = await getDocumentAsync({
+            type: DOCUMENT_MIME_TYPES,
+            copyToCacheDirectory: true,
         });
-
-        if (!result.canceled) {
-            upload(result.assets);
-        }
+        if (!result.canceled) upload(result.assets);
     } catch (err) {
         console.warn(err);
         Toast.fail('打开文件失败,请重试');
-    }
-    finally {
-        Toast.remove(l);
+    } finally {
+        Toast.remove(toastKey);
     }
 };
 
+async function uploadAndCreateCredit(assets: Asset[]) {
+    const file = new File(assets[0].uri);
+    const formData = new FormData();
+    formData.append('file', file);
+    const attachment = await api.uploadFile<Attachment>('common/upload', { body: formData });
+    await api.post('/credit/create', { att: JSON.stringify(attachment) });
+}
 
-export function UploadScreen({ visible, onClose }: { visible: boolean, onClose: (isCancel: boolean) => void }) {
-
+export function UploadScreen({
+    visible,
+    onClose,
+}: {
+    visible: boolean;
+    onClose: (isCancel: boolean) => void;
+}) {
+    const [state, setState] = useState<UploadState>(0);
 
     useEffect(() => {
-        if (!visible) {
-            setState(0);
-        }
+        if (!visible) setState(0);
     }, [visible]);
-    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;
-        }
+    const upload = useCallback<UploadHandler>(async (assets) => {
+        setState(1);
         try {
-            await api.post('/credit/create', {
-                att: JSON.stringify(att),
-            });
+            await uploadAndCreateCredit(assets);
             setState(2);
-            // eslint-disable-next-line @typescript-eslint/no-unused-vars
         } catch (err) {
+            console.warn(err);
             setState(0);
-            Toast.fail('添加分析到队列失败,请重试');
+            Toast.fail('上传失败,请重试');
         }
     }, []);
 
     return (
-        <Modal visible={visible} transparent={true} animationType="slide">
+        <Modal visible={visible} transparent animationType="slide">
             <View className="flex-row items-center">
                 <Ionicons name="cloud-upload" size={48} color={Colors.tint} />
                 <Text className="ml-4 text-3xl font-bold">征信分析</Text>
             </View>
             {state === 0 && (
                 <View className="w-72 h-auto px-6">
-
                     <Text className="mt-2 text-2xl">请选择征信上传方式</Text>
-                    <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="folder" onPress={() => pickDocument(upload)} className="mt-8" />
+                    {Platform.OS === 'ios' && (
+                        <UIButton title="从手机相册" icon="picture" onPress={() => pickImage(upload)} className="mt-4" />
+                    )}
                     <UIButton title="拍照上传" icon="camera" onPress={() => takePhoto(upload)} className="mt-4" />
-                </View>)}
-            {
-                state === 1 && (
-                    <View className="w-72 h-auto p-8">
-                        <ActivityIndicator size="large" color={Colors.tint} text="正在上传,请稍候..." />
-
+                </View>
+            )}
+            {state === 1 && (
+                <View className="w-72 h-auto p-8">
+                    <ActivityIndicator size="large" color={Colors.tint} text="正在上传,请稍候..." />
+                </View>
+            )}
+            {state === 2 && (
+                <>
+                    <View className="w-72 h-auto p-8 flex-row">
+                        <Ionicons name="time-outline" size={48} color={Colors.tint} />
+                        <Text className="mt-4 ml-4 text-2xl">AI 正在分析</Text>
                     </View>
-                )
-            }
-            {
-                state === 2 && (
-                    <>
-                        <View className="w-72 h-auto p-8 flex-row">
-                            <Ionicons name="time-outline" size={48} color={Colors.tint} />
-                            <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(state != 2)}>
-                                <Text className="text-primary">分析页</Text>
-                            </Link>
-                            查看状态和结果</Text>
-                    </>
-                )
-            }
+                    <Text className="mt-4 ml-4 text-2xl">
+                        <Link asChild href="/(tabs)/analytics" onPress={() => onClose(false)}>
+                            <Text className="text-primary">分析页</Text>
+                        </Link>
+                        查看状态和结果
+                    </Text>
+                </>
+            )}
             <View className="h-8" />
-            {state != 1 && <Pressable hitSlop={8} className="absolute top-0 right-4" onPress={() => { onClose(state !== 2); }}>
-                <Icon name="close" size={32} />
-            </Pressable>}
-        </Modal >
+            {state !== 1 && (
+                <Pressable
+                    hitSlop={8}
+                    className="absolute top-0 right-4"
+                    onPress={() => onClose(state !== 2)}
+                >
+                    <Icon name="close" size={32} />
+                </Pressable>
+            )}
+        </Modal>
     );
 }
 
+export function UploadComponent({ onComplete }: { onComplete: () => void }) {
+    const [state, setState] = useState<UploadState>(0);
+
+    const upload = useCallback<UploadHandler>(
+        async (assets) => {
+            setState(1);
+            try {
+                await uploadAndCreateCredit(assets);
+                setState(0);
+                onComplete?.();
+            } catch (err) {
+                console.warn(err);
+                setState(0);
+                Toast.fail('上传失败,请重试');
+            }
+        },
+        [onComplete]
+    );
 
-
-
-
-export function UploadComponent({ customerId, onCompolete }: { customerId?: number, 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(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) {
-            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);
+    const handleSelect = useCallback(
+        (index: number) => {
+            if (Platform.OS === 'ios') {
+                if (index === 0) pickDocument(upload);
+                else if (index === 1) pickImage(upload);
+                else if (index === 2) takePhoto(upload);
+                return;
             }
-        }
-        if (index == 2) {
-            takePhoto(upload);
-        }
+            if (index === 0) pickDocument(upload);
+            else if (index === 1) takePhoto(upload);
+        },
+        [upload]
+    );
 
-    }, []);
     const selectFile = useCallback(() => {
-        ActionSheet.showActionSheetWithOptions({
-            title: "请选择征信上传方式",
-            cancelButtonIndex: 3,
-            options: Platform.OS == 'ios' ? ['从本机文件', '从手机相册', '拍照上传', '取消'] : ['从本机文件', '拍照上传', '取消']
-        }, onSelect);
-    }, []);
-
+        ActionSheet.showActionSheetWithOptions(
+            {
+                title: '请选择征信上传方式',
+                cancelButtonIndex: Platform.OS === 'ios' ? 3 : 2,
+                options:
+                    Platform.OS === 'ios'
+                        ? ['从本机文件', '从手机相册', '拍照上传', '取消']
+                        : ['从本机文件', '拍照上传', '取消'],
+            },
+            handleSelect
+        );
+    }, [handleSelect]);
+
+    const isUploading = state === 1;
     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 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={isUploading}
+                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>
+                {isUploading ? (
+                    <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': isUploading,
+                    })}
                 >
-                    <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>
-                    {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>
-        </>
+                    支持 PDF、图片格式,最大 20MB
+                </Text>
+            </Pressable>
+        </View>
     );
 }
-

+ 39 - 99
src/utils/api.ts

@@ -17,7 +17,8 @@ export function getAccessToken() {
         return null;
     }
     const now = Date.now() / 1000;
-    if (accessToken.expiresAt < now - 30) {
+    // 提前 30 秒判过期,避免请求路上 token 失效
+    if (accessToken.expiresAt < now + 30) {
         accessToken = null;
     }
     return accessToken;
@@ -76,7 +77,7 @@ export interface ListResponse<T = unknown> {
     list: T[];
 }
 
-const verString = (() => {
+const appVersion = (() => {
     const expoConfig = Constants.expoConfig;
     const versionName = expoConfig?.version || '';
     const platformSpecific = Platform.OS === 'android' ? expoConfig?.android?.versionCode || '' : expoConfig?.ios?.buildNumber || '';
@@ -88,7 +89,7 @@ const apiClient = axios.create({
 
     headers: {
         "x-app-name": Constants.expoConfig?.slug || 'unknown',
-        "x-app-version": verString,
+        "x-app-version": appVersion,
         "x-app-platform": Platform.OS,
         'Content-Type': 'application/json'
     },
@@ -99,141 +100,80 @@ const apiClient = axios.create({
 
 });
 
-// 开发模式下输出请求和响应数据
 if (__DEV__) {
-    // 请求拦截器:输出请求信息
-    apiClient.interceptors.request.use(
-        (config) => {
-            console.log('📤 API 请求:', {
-                url: config.url,
-                method: config.method?.toUpperCase(),
-                baseURL: config.baseURL,
-                headers: config.headers,
-                params: config.params,
-                data: config.data,
-            });
-            return config;
-        },
-        (error) => {
-            console.log('❌ API 请求配置错误:', error);
-            return Promise.reject(error);
-        }
-    );
-
-    // 响应拦截器:输出响应数据
+    apiClient.interceptors.request.use((config) => {
+        console.log('📤', config.method?.toUpperCase(), config.url, config.data ?? config.params ?? '');
+        return config;
+    });
     apiClient.interceptors.response.use(
         (response) => {
-            console.log('📡 API 响应:', {
-                url: response.config.url,
-                method: response.config.method?.toUpperCase(),
-                status: response.status,
-                statusText: response.statusText,
-                headers: response.headers,
-                data: response.data,
-            });
+            console.log('📡', response.status, response.config.url, response.data);
             return response;
         },
         (error) => {
-            if (error.response) {
-                console.log('❌ API 错误响应:', {
-                    url: error.config?.url,
-                    method: error.config?.method?.toUpperCase(),
-                    status: error.response.status,
-                    statusText: error.response.statusText,
-                    headers: error.response.headers,
-                    data: error.response.data,
-                });
-            } else if (error.request) {
-                console.log('❌ API 请求错误:', {
-                    message: error.message,
-                    request: error.request,
-                });
-            } else {
-                console.log('❌ API 错误:', error.message);
-            }
+            console.log('❌', error.config?.url, error.response?.status, error.response?.data ?? error.message);
             return Promise.reject(error);
         }
     );
 }
 
+const COMMON_HEADERS: Record<string, string> = {
+    'x-app-name': Constants.expoConfig?.slug || 'unknown',
+    'x-app-version': appVersion,
+    'x-app-platform': Platform.OS,
+};
+
+function withAuth(extra?: Record<string, any>): Record<string, string> {
+    const headers: Record<string, string> = { ...COMMON_HEADERS, ...extra };
+    const token = getAccessToken();
+    if (token?.token) headers['Authorization'] = `Bearer ${token.token}`;
+    return headers;
+}
+
 type Options = Omit<AxiosRequestConfig, 'headers'> & { headers?: Record<string, any> };
 async function request<T>(url: string, method: 'get' | 'post' | 'put' | 'delete', data?: any, config?: Options): Promise<T> {
-    const headers: Record<string, string> = {
-        'Content-Type': 'application/json',
-        ...config?.headers
-    };
-
-    let token = getAccessToken();
-    if (token?.token) {
-        headers['Authorization'] = `Bearer ${token.token}`;
-    }
+    const headers = withAuth({ 'Content-Type': 'application/json', ...config?.headers });
     try {
         const response = await apiClient.request<ApiResponse<T>>({
-            url,
-            method,
-            data,
-            ...config,
-            headers,
+            url, method, data, ...config, headers,
         });
-        if (200 !== response.status) {
+        if (response.status !== 200) {
             throw new HttpError(response.statusText || 'http error', response.status);
         }
         const res = response.data;
-
-
         if (`${res?.code}` !== '1') {
             throw new ApiError(res?.msg || response.statusText, res?.code || 0, res?.data);
         }
         return res?.data as T;
     } catch (error) {
-        if (error instanceof ApiError || error instanceof HttpError) {
-            throw error;
-        }
+        if (error instanceof HttpError) throw error;
         // @ts-ignore
-        throw new HttpError(error?.message || (`${error}`) || "unknown error", NaN);
+        throw new HttpError(error?.message || `${error}` || 'unknown error', NaN);
     }
 }
-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 rawRequest = async <T>(req: AxiosRequestConfig) => {
+    const headers = withAuth(req.headers as Record<string, any> | undefined);
     const res = await axios.request<T>({
         ...req,
-        timeout: apiConfig.timeout || 10000,
-        headers
+        timeout: 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 uploadFile<T>(url: string, config?: FetchRequestInit): Promise<T> {
-    const headers: Record<string, any> = {
-        // 'Content-Type': 'multipart/form-data',
-        ...config?.headers,
-    };
-    const token = getAccessToken();
-    if (token?.token) {
-        headers['Authorization'] = `Bearer ${token.token}`;
-    }
+    const headers = withAuth(config?.headers as Record<string, any> | undefined);
     const result = await fetch(`${apiConfig.url}${url}`, {
         method: 'POST',
-        ... config,
+        ...config,
         headers
     });
-    console.log('上传文件响应状态:', result);
     if (result.status !== 200) {
-        throw new HttpError((await result.bytes()).toString(), result.status);
+        throw new HttpError(await result.text().catch(() => 'upload error'), result.status);
     }
     const res = await result.json() as ApiResponse<T>;
     if (`${res?.code}` !== '1') {
@@ -255,12 +195,12 @@ async function put<T>(api: string, data?: any, config?: Options): Promise<T> {
     return await request<T>(api, 'put', data, config);
 }
 
-async function deleted<T>(api: string, config?: Options): Promise<T> {
+async function del<T>(api: string, config?: Options): Promise<T> {
     return await request<T>(api, 'delete', undefined, config);
 }
 const api = {
-    post, get, put, deleted, request, rawRequest, uploadFile
-}
+    post, get, put, del, request, rawRequest, uploadFile,
+};
 
 
 export default api;

+ 27 - 32
src/utils/cache.ts

@@ -1,25 +1,26 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { getApiCache } from "./storage";
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getApiCache } from './storage';
 
-
-
-interface UseSWCOPtions<T> {
+interface UseSWCOptions<T> {
     onError?: (e: unknown) => void;
     onLoad?: (data: T) => void;
     cacheOnly?: boolean;
     cacheTimeout?: number;
-    // 自动开始加载
     autoStart?: boolean;
 }
-// 该hook用于在组件中通过key和异步action获取数据,并自动处理加载、错误和数据状态
 
-export function useSWC<T, S extends any[] = any>(key: string, action: () => Promise<T>, options?: UseSWCOPtions<T>) {
+const DEFAULT_CACHE_TTL = 86400 * 120;
+
+// 该 hook 在组件中按 key 和 action 拉取数据,自动处理加载/错误/缓存(stale-while-revalidate)
+export function useSWC<T>(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>(() => getApiCache().getObject<T>(keyRef.current));
+    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);
+    const [error, setError] = useState<unknown>(null);
 
     const actionRef = useRef(action);
     actionRef.current = action;
@@ -27,49 +28,45 @@ export function useSWC<T, S extends any[] = any>(key: string, action: () => Prom
     const dataRef = useRef(data);
     dataRef.current = data;
     const isMountedRef = useRef(true);
-    const load = useCallback(async ()=> {
+
+    const load = useCallback(async () => {
         setLoading(dataRef.current ? 'background' : true);
         setError(null);
-         
-        const options = optionsRef.current;
-        const key = keyRef.current;
+        const opts = optionsRef.current;
+        const cacheKey = keyRef.current;
         try {
             const result = await actionRef.current();
-            // @ts-ignore
-            result && getApiCache().setObject(key, result, options.cacheTimeout || 86400 * 120);
+            if (result) {
+                getApiCache().setObject(cacheKey, result, opts.cacheTimeout || DEFAULT_CACHE_TTL);
+            }
             if (isMountedRef.current) {
-                options?.onLoad?.(result);
+                opts.onLoad?.(result);
                 setData(result);
                 setLoading(false);
             }
-            
-        } catch(err) {
+        } catch (err) {
             if (isMountedRef.current) {
-                options?.onError?.(err);
+                opts.onError?.(err);
                 setError(err);
                 setLoading(false);
             }
         }
-    }, [])
+    }, []);
+
     useEffect(() => {
+        isMountedRef.current = true;
         if (dataRef.current && optionsRef.current.cacheOnly) {
-
             setLoading(false);
             setError(null);
-            return;
-        }
-        
-        // @ts-ignore
-        if ((optionsRef.current.autoStart !== false || optionsRef.current.autoStart)) {
+        } else if (optionsRef.current.autoStart !== false) {
             load();
         }
-     
         return () => {
             isMountedRef.current = false;
         };
-    }, []);
+    }, [load]);
 
-    const getOrLoad = useCallback(async ()=> {
+    const getOrLoad = useCallback(async () => {
         if (dataRef.current && optionsRef.current.cacheOnly) {
             setLoading(false);
             setError(null);
@@ -80,5 +77,3 @@ export function useSWC<T, S extends any[] = any>(key: string, action: () => Prom
 
     return { data, loading, error, load: getOrLoad, refresh: load };
 }
-
-export const MAX_CACHE_TIME = 99999999999999;

+ 0 - 0
src/utils/os.tsx → src/utils/os.ts


+ 57 - 97
src/utils/storage.ts

@@ -1,116 +1,76 @@
-// import RNDeviceInfo from 'react-native-device-info';
 import * as fs from 'expo-file-system/legacy';
-
 import { createMMKV, MMKV } from 'react-native-mmkv';
 
 const TTL_KEY = '$__$t_';
+const LAST_CLEAR_KEY = '$__last_clear_time$_';
+const CLEAR_INTERVAL = 86400 * 3 * 1000; // 3 天扫一次过期项
+const CLEAR_TICK = 60 * 15 * 1000; // 每 15 分钟检查一次
+
 function fixPath(path: string) {
-    if (path.startsWith("file:///")) {
-        return path.substring(7);
-    }
-    if (path.startsWith("file:/")) {
-        return path.replace(/^file:\//, "/");
-    }
+    if (path.startsWith('file:///')) return path.substring(7);
+    if (path.startsWith('file:/')) return path.replace(/^file:\//, '/');
+    return path;
 }
 
-type KVDB = MMKV & {
+export type KVDB = MMKV & {
     getObject: <T = any>(key: string) => T | undefined;
-    setObject: (key: string, value: any, tt: number)=> void;
-}
+    setObject: (key: string, value: any, ttlSeconds: 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) =>  {
+function attachObjectMethods(mmkv: MMKV): KVDB {
+    const db = mmkv as KVDB;
+    db.setObject = (key, value, ttlSeconds) => {
+        db.set(
+            key,
+            JSON.stringify({ [TTL_KEY]: Date.now() + ttlSeconds * 1000, v: value })
+        );
+    };
+    db.getObject = <T>(key: string) => {
+        const raw = db.getString(key);
+        if (!raw) return undefined;
         try {
-            const obj = JSON.parse(that.getString(key)!) as {
-                [TTL_KEY]: number;
-                v: T
-            };
-            if (Date.now() - obj[TTL_KEY] > -1) {
-                that.remove(key);
+            const obj = JSON.parse(raw) as { [TTL_KEY]: number; v: T };
+            if (Date.now() > obj[TTL_KEY]) {
+                db.remove(key);
                 return undefined;
             }
             return obj.v;
-        } catch(e) {
-            // that.remove(key);
+        } catch {
+            db.remove(key);
+            return undefined;
         }
-       
-    }
-}
-
-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: 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;
+    };
+    return db;
 }
 
-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;
+function makeStore(id: string) {
+    let instance: KVDB | null = null;
+    return () => {
+        if (!instance) {
+            instance = attachObjectMethods(
+                createMMKV({ id, path: fixPath(fs.cacheDirectory ?? '') })
+            );
+        }
+        return instance;
+    };
 }
 
+export const getGlobalStorage = makeStore('global');
+export const getCaches = makeStore('caches');
+export const getApiCache = makeStore('api_cache');
 
-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);
+// 启动后定期扫一遍缓存,清理过期项
+setTimeout(() => {
+    const tick = () => {
+        const global = getGlobalStorage();
+        const last = parseInt(global.getString(LAST_CLEAR_KEY) || '0', 10);
+        const now = Date.now();
+        if (now - last < CLEAR_INTERVAL) return;
+        for (const db of [getCaches(), getApiCache(), global]) {
+            for (const key of db.getAllKeys()) db.getObject(key);
+        }
+        global.set(LAST_CLEAR_KEY, now);
+    };
+    tick();
+    setInterval(tick, CLEAR_TICK);
+}, 3000);

+ 40 - 26
src/utils/storage.web.ts

@@ -1,32 +1,46 @@
-// import RNDeviceInfo from 'react-native-device-info';
 import { createMMKV, MMKV } from 'react-native-mmkv';
 
-let globalStorage: MMKV | null = null;
-export function getGlobalStorage() {
-    if (!globalStorage) {
-        globalStorage = createMMKV({
-            id: `global`,
-        });
-    }
-    return globalStorage;
+const TTL_KEY = '$__$t_';
+
+export type KVDB = MMKV & {
+    getObject: <T = any>(key: string) => T | undefined;
+    setObject: (key: string, value: any, ttlSeconds: number) => void;
+};
+
+function attachObjectMethods(mmkv: MMKV): KVDB {
+    const db = mmkv as KVDB;
+    db.setObject = (key, value, ttlSeconds) => {
+        db.set(
+            key,
+            JSON.stringify({ [TTL_KEY]: Date.now() + ttlSeconds * 1000, v: value })
+        );
+    };
+    db.getObject = <T>(key: string) => {
+        const raw = db.getString(key);
+        if (!raw) return undefined;
+        try {
+            const obj = JSON.parse(raw) as { [TTL_KEY]: number; v: T };
+            if (Date.now() > obj[TTL_KEY]) {
+                db.remove(key);
+                return undefined;
+            }
+            return obj.v;
+        } catch {
+            db.remove(key);
+            return undefined;
+        }
+    };
+    return db;
 }
 
-let caches: MMKV | null = null;
-export function getCaches() {
-    if (!caches) {
-        caches = createMMKV({
-            id: `caches`
-        });
-    }
-    return caches;
+function makeStore(id: string) {
+    let instance: KVDB | null = null;
+    return () => {
+        if (!instance) instance = attachObjectMethods(createMMKV({ id }));
+        return instance;
+    };
 }
 
-let apiCache: MMKV | null = null;
-export function getApiCache() {
-    if (!apiCache) {
-        apiCache = createMMKV({
-            id: `api_cache`
-        });
-    }
-    return apiCache;
-}
+export const getGlobalStorage = makeStore('global');
+export const getCaches = makeStore('caches');
+export const getApiCache = makeStore('api_cache');

+ 3 - 5
src/utils/tsutils.ts

@@ -1,5 +1,3 @@
-export function isPromise(obj: any) {
-    return (obj instanceof Promise ||
-        Object.prototype.toString.call(obj)
-        === "[object Promisel");
-}
+export function isPromise<T = unknown>(obj: any): obj is PromiseLike<T> {
+    return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
+}

binární
unused-src-files-20260501.zip