# ReactNativeApp **Repository Path**: null_775_7982/react-native-app ## Basic Information - **Project Name**: ReactNativeApp - **Description**: ReactNativeApp - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-08-14 - **Last Updated**: 2026-08-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 移动工作台 这是一个使用 Expo、React Native 和 TypeScript 开发的 Android/iOS 跨端应用,也支持在浏览器中调试普通页面。 当前阶段已经搭建页面、底部导航、基础 UI、网络请求、服务端状态管理和登录缓存。地图、定位和 NFC 尚未接入。 ## 技术栈 - Expo SDK 57 - React Native 0.86 - React 19 - TypeScript - Expo Router:文件路由和页面导航 - RNEUI:按钮、输入框、弹窗等通用 UI 组件 - Lucide React Native:图标 - React Native Web:浏览器调试 - Expo Image:本地和远程位图图片展示、缓存和加载过渡 - React Native SVG + SVG Transformer:自定义 SVG 图标和 Logo 导入 - Axios:HTTP 请求、超时、请求头和拦截器 - TanStack Query:服务端数据缓存、加载状态、错误状态、刷新和重试 - Day.js:日期解析、格式化和中文相对时间 - AsyncStorage:持久化保存用户资料、权限和企业资料 ## 安装与启动 首次安装依赖: ```bash npm install --legacy-peer-deps ``` 启动 Expo: ```bash npm start ``` 启动后可以: - 使用 Expo Go 扫描终端二维码,在手机上运行。 - 执行 `npm run web`,同时启动浏览器版本和本地 API 代理。 - 如果通过 `npm start` 后按 `w` 打开浏览器,需要在另一个终端执行 `npm run proxy`。 微信不能用于运行 Expo 项目,二维码需要使用 Expo Go 扫描。手机和电脑应连接同一局域网;连接失败时可以使用: ```bash npx expo start --tunnel ``` 类型检查: ```bash npm run typecheck ``` ## 项目目录 ```text ReactNativeApp/ ├─ app/ # Expo Router 页面和路由 │ ├─ _layout.tsx # 应用根布局、全局主题 │ ├─ index.tsx # 应用默认入口 │ ├─ (home)/ # 底部主导航分组 │ │ ├─ _layout.tsx # 底部导航配置 │ │ ├─ qa.tsx # 准答 │ │ ├─ ent.tsx # 企业 │ │ ├─ capture.tsx # 随手拍 │ │ ├─ knowledge.tsx # 知识号 │ │ └─ my.tsx # 我的 │ └─ my/ │ └─ enterprises.tsx # 我的企业二级页面 ├─ components/ │ └─ ui/ # 多个页面复用的基础组件 ├─ assets/ # 图片、图标和字体等静态资源 │ ├─ images/ # 页面图片和业务图片 │ ├─ icons/ # 自定义图标 │ └─ fonts/ # 自定义字体 ├─ constants/ # 缓存键、状态值和业务字典 │ └─ storage-keys.ts # 本地缓存键 ├─ api/ # 按业务定义后端接口 │ └─ enterprise.ts # 企业接口示例 ├─ config/ │ └─ env.ts # 开发/生产环境和接口地址 ├─ hooks/ │ └─ useEnterprises.ts # 企业 Query 和 Mutation Hooks ├─ services/ │ ├─ http/ │ │ ├─ client.ts # Axios 实例和拦截器 │ │ ├─ error-handler.ts # 接口错误提示、登录失效处理 │ │ └─ types.ts # 通用响应、错误类型 │ └─ query/ │ └─ client.ts # TanStack Query 全局配置 ├─ storage/ │ ├─ local-cache.ts # SecureStore/Web 跨平台持久化缓存 │ ├─ account-cache.ts # 当前登录账号、Token 和租户缓存 │ └─ memory-cache.ts # 本次运行期间的临时内存缓存 ├─ api-types/ │ └─ account.ts # 登录、用户、角色、部门和企业数据类型 ├─ theme/ │ ├─ tokens.ts # 颜色、间距和圆角变量 │ └─ theme.ts # RNEUI 全局主题 ├─ app.json # Expo 应用名称、包名等配置 ├─ package.json # 依赖和 npm 命令 └─ tsconfig.json # TypeScript 配置 ``` ### 统一视觉规范 主题变量位于 `theme/tokens.ts`,目前的基础规范如下: | 用途 | 颜色/字号 | | --- | --- | | 主色 | `#277EF1` | | 默认文字 | `#333333` | | 次要文字 | `#333333` | | 提示文字 | `#999999` | | 正文、说明文字 | `14px` | | 重要文字、按钮 | `16px` | | 辅助提示 | `12px` | | 页面标题 | `24px` | | 默认行高 | 字号 × `1.4` | React Native 的字号单位是逻辑像素,不是 CSS 的物理像素。系统会根据设备密度自动换算显示尺寸;同时 `Text` 默认支持系统字体大小设置,因此一般不需要自己按屏幕宽度缩放字号。固定的设计字号配合 `allowFontScaling`,在 Android 和 iOS 上会更稳定。只有大标题、复杂表格等特殊场景,才需要额外处理小屏适配。 页面中优先使用主题变量,避免到处写颜色和字号: ```tsx import { tokens } from '../theme/tokens'; const styles = StyleSheet.create({ text: { color: tokens.colors.text, fontSize: tokens.font.bodySize, lineHeight: Math.round(tokens.font.bodySize * tokens.font.lineHeightRatio), }, }); ``` ## 页面代码写在哪里 底部主页面直接写在 `app/(home)`: | 文件 | 页面 | 实际路径 | |---|---|---| | `qa.tsx` | 准答 | `/qa` | | `ent.tsx` | 企业 | `/ent` | | `capture.tsx` | 随手拍 | `/capture` | | `knowledge.tsx` | 知识号 | `/knowledge` | | `my.tsx` | 我的 | `/my` | 普通二级页面按照业务放到对应目录。例如: ```text app/my/enterprises.tsx -> /my/enterprises app/ent/detail.tsx -> /ent/detail ``` 当前约定是尽量不拆分简单页面。页面布局、局部小组件、临时数据和样式可以先放在同一个页面文件中。出现以下情况再拆组件: - 同一组件被多个页面使用。 - 页面文件已经很长,阅读和维护明显困难。 - 某个区域有独立且复杂的状态或业务逻辑。 跨页面复用的组件放入 `components`,不要为了拆分而拆分。 其他代码位置: | 内容 | 目录 | |---|---| | 页面、路由、页面入口 | `app/` | | 多页面复用组件 | `components/` | | 图片、图标、字体等静态资源 | `assets/` | | 缓存键、状态值和业务字典 | `constants/` | | 后端接口函数 | `api/` | | 日期格式化、Toast、权限等通用工具 | `utils/` | | 页面请求 Hooks | `hooks/` | | Axios、Query 等基础服务 | `services/` | | 环境配置 | `config/` | | 颜色和全局主题 | `theme/` | ## Expo Router 导航 `(home)` 中的括号表示路由分组。它只用于组织代码,不会出现在 URL 中。 `_layout.tsx` 是 Expo Router 约定的布局文件。`app/(home)/_layout.tsx` 使用 `Tabs` 创建底部导航: ```tsx import { Tabs } from 'expo-router'; import { BriefcaseBusiness } from 'lucide-react-native'; ( ), }} /> ``` 其中: - `name="ent"` 对应同目录的 `ent.tsx`。 - `title="企业"` 是底部显示的文字。 - `tabBarIcon` 设置底部图标。 - `color` 和 `size` 由导航组件自动传入,用来显示选中/未选中状态。 文件名必须与 `Tabs.Screen` 的 `name` 保持一致。 默认入口在 `app/index.tsx`: ```tsx import { Redirect } from 'expo-router'; export default function Index() { return ; } ``` 页面跳转: ```tsx import { router } from 'expo-router'; router.push('/my/enterprises'); router.back(); ``` 携带参数: ```tsx router.push({ pathname: '/ent/detail', params: { id: '1001' }, }); ``` 读取参数: ```tsx import { useLocalSearchParams } from 'expo-router'; const { id } = useLocalSearchParams<{ id: string }>(); ``` ## 应用根入口和 Provider `app/_layout.tsx` 是整个应用的根布局。所有页面都会运行在这里配置的 Provider 内: ```tsx ``` - `SafeAreaProvider`:处理刘海屏、状态栏和底部安全区。 - `QueryClientProvider`:让所有页面可以使用 TanStack Query。 - `ThemeProvider`:让所有页面可以使用 RNEUI 主题。 - `Stack`:渲染当前 Expo Router 页面,并支持页面入栈和返回。 新增全局能力时通常在这个根布局挂 Provider,例如登录状态、国际化或全局弹窗。 ## 网络请求框架 项目使用 Axios 和 TanStack Query 配合: ```text 页面 ↓ 调用 useMyEnterprises() hooks/useEnterprises.ts ↓ 调用 getMyEnterprises() api/enterprise.ts ↓ 使用 http.get() services/http/client.ts ↓ 请求后端 API ``` 职责划分: - Axios 负责真正发送 HTTP 请求。 - `api` 文件负责定义接口地址、参数和返回类型。 - TanStack Query Hook 负责页面加载状态、错误、缓存、刷新和重试。 - 页面只使用 Hook,不在 JSX 中直接写 Axios 请求。 ### 配置接口地址 修改 `config/env.ts`。当前 Android/iOS 直接连接测试服务器,Web 开发环境通过本地代理解决 CORS: ```tsx const WEB_DEV_API_BASE_URL = 'http://127.0.0.1:3001'; const REMOTE_API_BASE_URL = 'https://test.chatsafe.cn'; const apiBaseUrl = __DEV__ && Platform.OS === 'web' ? WEB_DEV_API_BASE_URL : REMOTE_API_BASE_URL; ``` `scripts/api-proxy.cjs` 只用于本机 Web 调试,代理会把请求转发到真实后端并响应浏览器的 CORS 预检。正式 Web 部署时仍需要后端允许正式网站域名,或在部署服务器配置反向代理。 Android 模拟器访问电脑本机服务时,通常不能写 `localhost`,可使用: ```text http://10.0.2.2:后端端口 ``` 真机访问电脑本机服务时,应使用电脑的局域网 IP,例如: ```text http://192.168.1.10:8080 ``` ### Axios 实例 `services/http/client.ts` 统一配置: ```tsx export const http = axios.create({ baseURL: env.apiBaseUrl, timeout: 15_000, headers: { 'Content-Type': 'application/json', }, }); ``` 不要在每个页面重复填写服务器地址和超时时间。 请求拦截器统一添加 Token 和租户 ID: ```tsx http.interceptors.request.use(config => { const token = accountCache.getToken(); const tenantId = accountCache.getTenantId(); if (token) { config.headers.set('Authorization', `Bearer ${token}`); } if (tenantId) { config.headers.set('TENANT-ID', tenantId); } return config; }); ``` 响应拦截器只负责把业务错误和 HTTP 错误转换成 `ApiError`,不负责弹窗或页面跳转: 后端统一响应结构: ```tsx type ApiResponse = { code: number; msg: string | null; data: T; ok: boolean; }; ``` 其中 `code === 0` 表示成功,其他值表示业务错误。 ```tsx http.interceptors.response.use( response => { if (response.data && response.data.code !== 0) { return Promise.reject(new ApiError(response.data.msg)); } return response; }, error => { const status = error.response?.status; // 423:演示环境禁止操作 return Promise.reject(new ApiError('网络请求失败', status)); }, ); ``` 当前拦截逻辑参考 Web 项目的 `request.ts`,保留了移动端需要的部分: - 自动添加 `Authorization: Bearer `。 - 自动添加 `TENANT-ID`。 - 业务响应 `code !== 0` 时抛出统一错误。 - HTTP `423` 转换为“演示环境,仅供预览”。 - 所有异常转换为 `ApiError`,页面统一读取 `error.message`。 没有迁移 Web 专属或当前不需要的逻辑:请求加解密、微服务 URL 自动适配、灰度版本请求头、Element Plus 弹窗、浏览器存储和 `window.location` 跳转。 ### 接口错误处理类 `services/http/error-handler.ts` 专门负责错误类型对应的用户反馈: - `423`:提示当前为演示环境。 - `424`:清空登录状态,提示登录过期。 - `426`:清空登录状态,提示企业状态过期。 - 其他错误:显示接口返回的错误消息。 项目已经在 `services/query/client.ts` 中注册了全局错误回调: ```tsx export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: error => { void apiErrorHandler.handle(error); }, }), mutationCache: new MutationCache({ onError: error => { void apiErrorHandler.handle(error); }, }), }); ``` 因此通过 `useQuery` 和 `useMutation` 发出的请求失败后会自动调用错误处理器,页面不用重复写 `onError`。普通的 `try/catch` 请求不经过 TanStack Query,需要手动调用: ```tsx try { await saveEnterprise(form); } catch (error) { await apiErrorHandler.handle(error); } ``` 登录过期时可以注册跳转动作: ```tsx apiErrorHandler.setAuthExpiredAction(() => router.replace('/login')); ``` 这样 `client.ts` 保持纯请求层,不会因为请求失败直接操作 UI;同一个错误处理类也被 Query、Mutation 和普通 `try/catch` 复用。 登录成功后写入账号缓存: ```tsx await accountCache.saveLogin({ token: loginResult.token, refreshToken: loginResult.refreshToken, tenantId: loginResult.tenantId, user: loginResult.user, }); ``` 短信登录成功后的初始化顺序如下: 1. 保存 `access_token`、`refresh_token` 和 `tenantId`,使请求拦截器可以立即读取。 2. 并行请求 `GET /api/admin/user/info` 和 `GET /api/datacenter/syncEnterpriseData/info`。 3. 两个接口都成功后,持久化用户资料和企业资料,再进入首页。 4. 任一接口失败时清除本次未完成的登录状态,并停留在登录页显示错误。 登录后的普通接口会由 `http` 请求拦截器自动添加: ```text Authorization: Bearer Tenant-Id: ``` 页面和接口函数不需要重复填写这些请求头。浏览器截图中的 `Cookie` 由网站环境管理,React Native App 不手动设置 Cookie。 `storage/local-cache.ts` 是统一持久化工具类:Android/iOS 使用 `expo-secure-store` 加密存储,Web 调试使用 `localStorage`。`storage/account-cache.ts` 启动时把当前账号数据恢复到内存,Axios 拦截器可以同步读取 Token,不需要每个请求都访问磁盘。 账号缓存按数据性质分开:Token、Refresh Token 和租户 ID 使用 SecureStore;体积较大的用户权限和企业资料使用 AsyncStorage。MMKV 也能替换 AsyncStorage,但它是原生模块,需要 Development Build;当前资料只在登录和启动时读写,AsyncStorage 已足够。 简单区分: - `accountCache`:Token、租户和用户信息,会持久化保存。 - `memoryCache`:本次 App 运行期间的临时数据,重启后自动消失。 - `SecureStore`:原生端加密保存小体积敏感数据。 - MMKV:适合大量、高频的普通配置缓存,当前登录数据暂不需要。 - Query 缓存:接口数据缓存,默认只在 App 运行期间保留。 普通小型缓存也可以通过工具类读写: ```tsx import { localCache } from '@/storage/local-cache'; import { memoryCache } from '@/storage/memory-cache'; await localCache.set('theme_mode', 'light'); const themeMode = await localCache.get('theme_mode'); await localCache.remove('theme_mode'); // 临时数据只存在内存中 memoryCache.set('selected_enterprise_id', '1001'); const enterpriseId = memoryCache.get('selected_enterprise_id'); ``` 退出登录或调试时需要一次清空全部缓存: ```tsx import { accountCache } from '@/storage/account-cache'; await accountCache.clearAll(); ``` 它会同时清除登录内存、MemoryCache 临时数据、SecureStore/localStorage 持久化数据和 TanStack Query 接口缓存。仅清除持久化键时也可以调用 `await localCache.clearAll()`;清除临时内存数据时调用 `memoryCache.clearAll()`。 登录页完成后,可在应用启动处注册失效跳转: ```tsx setAuthExpiredHandler(() => { router.replace('/login'); }); ``` ### 定义接口 接口按业务写在 `api` 目录。企业 GET 请求示例: ```tsx export async function getMyEnterprises() { const response = await http.get>( '/v1/my/enterprises', ); return response.data.data; } ``` POST 请求示例: ```tsx export async function switchEnterprise(enterpriseId: string) { const response = await http.post>( `/v1/my/enterprises/${enterpriseId}/switch`, ); return response.data.data; } ``` POST 携带请求体: ```tsx await http.post('/v1/login', { username, password, }); ``` GET 携带查询参数: ```tsx await http.get('/v1/enterprises', { params: { page: 1, pageSize: 20, keyword: '建材', }, }); ``` ### 在页面中查询数据 项目已提供 `hooks/useEnterprises.ts`。页面中这样使用: ```tsx import { ActivityIndicator, Pressable, Text, View } from 'react-native'; import { useMyEnterprises } from '../../hooks/useEnterprises'; export default function EnterprisesPage() { const { data = [], error, isLoading, refetch, } = useMyEnterprises(); if (isLoading) { return ; } if (error) { return ( {error.message} refetch()}> 重新加载 ); } return ( {data.map(item => ( {item.name} ))} ); } ``` 当前企业页面仍使用静态演示数据,因为还没有真实后端地址。配置真实 `apiBaseUrl` 后再启用这个 Hook。 ### 提交和修改数据 切换企业使用 Mutation: ```tsx import { useSwitchEnterprise } from '../../hooks/useEnterprises'; const switchEnterprise = useSwitchEnterprise(); switchEnterprise.mutate('enterprise-1')} > {switchEnterprise.isPending ? '切换中...' : '切换企业'} ``` 请求成功后 Hook 会让企业列表缓存失效并重新获取: ```tsx onSuccess: () => { queryClient.invalidateQueries({ queryKey: enterpriseKeys.all, }); } ``` ### 下拉刷新 `ScrollView` 可以结合 Query 的 `refetch`: ```tsx import { RefreshControl, ScrollView } from 'react-native'; } > {/* 页面内容 */} ``` 长列表建议使用 `FlatList`: ```tsx item.id} renderItem={({ item }) => {item.name}} refreshing={isRefetching} onRefresh={refetch} /> ``` 后续实现上拉分页时使用 TanStack Query 的 `useInfiniteQuery` 配合 `FlatList.onEndReached`。 ## Zustand 跨页面状态 Zustand 用于保存多个页面共享的临时业务状态,例如多步骤表单草稿、筛选条件和跨页面选择结果。 学习示例放在 `example/zustand/`: ```text example/zustand/ ├─ enterprise-draft.ts ├─ enterprise-edit.example.tsx ├─ enterprise-confirm.example.tsx └─ README.md ``` 组件中只订阅自己需要的字段: ```tsx const name = useEnterpriseDraftStore(state => state.name); const setName = useEnterpriseDraftStore(state => state.setName); ``` 提交成功后清空草稿: ```tsx const reset = useEnterpriseDraftStore(state => state.reset); reset(); ``` Zustand 默认是内存状态,App 重新加载后不会保留。接口数据使用 TanStack Query,Token 和用户信息使用 `storage/account-cache.ts`,短期临时数据可以使用 `storage/memory-cache.ts`。 ## React Native 基础组件 ### 登录页面和 React Hooks 登录页面现在位于: ```text app/login/index.tsx -> /login ``` `LoginScreen` 只是组件函数名,不是 React Native 的固定关键字。它表示“登录页面组件”,也可以命名为 `LoginPage`、`AuthLoginScreen` 等。默认导出的组件名可以自由命名,但建议使用“业务名 + Screen/Page”的方式保持清晰。 React 的 `useState` 类似 Vue 里的 `ref`,用来保存会变化的页面状态: ```tsx const [phone, setPhone] = useState(''); ``` 这里的 `phone` 是当前值,`setPhone` 是修改值的方法。对应 Vue: ```ts const phone = ref(''); phone.value = '13800138000'; ``` React 中不能直接写 `phone = '...'`,必须调用 `setPhone('...')`,调用后组件会重新渲染。TypeScript 泛型写法 `useState('oneClick')` 表示这个状态只能是 `LoginMode` 类型。 `useEffect` 类似 Vue 的 `watch`、`watchEffect` 和部分生命周期逻辑,用来处理定时器、订阅、请求等副作用: ```tsx useEffect(() => { if (countdown <= 0) return undefined; const timer = setInterval(() => { setCountdown(value => Math.max(0, value - 1)); }, 1000); return () => clearInterval(timer); }, [countdown]); ``` `[countdown]` 表示 `countdown` 改变时重新执行;返回的函数是清理函数,类似 Vue `onUnmounted`,用于清除旧定时器,避免页面离开后计时器仍然运行。 ```tsx useEffect(() => () => setCountdown(0), []); ``` `[]` 表示只在组件生命周期开始时注册一次,返回的清理函数会在页面卸载时执行。登录成功时也会主动把倒计时设置为 `0`。 React Native 不使用网页中的 `div`、`span` 和普通 `button`。常用对应关系: | React Native | 用途 | |---|---| | `View` | 页面布局容器,类似 `div` | | `Text` | 显示文字 | | `Pressable` | 可点击区域 | | `ScrollView` | 内容滚动 | | `TextInput` | 文本输入 | | `Image` | 显示图片 | | `FlatList` | 长列表和分页列表 | | `StyleSheet` | 创建样式 | 文件中使用什么,就需要导入什么: ```tsx import { Pressable, StyleSheet, Text, View } from 'react-native'; ``` 没有使用的组件不需要导入。新版 React 通常不需要额外编写 `import React from 'react'`,但是 Hooks 仍需导入: ```tsx import { useEffect, useState } from 'react'; ``` ## 页面与样式示例 ```tsx import { Pressable, StyleSheet, Text, View } from 'react-native'; export default function ExamplePage() { function handlePress() { console.log('点击了按钮'); } return ( 企业信息 [ styles.button, pressed && styles.buttonPressed, ]} > 查看详情 ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, }, title: { color: '#172033', fontSize: 20, fontWeight: '700', }, button: { backgroundColor: '#1677FF', marginTop: 16, padding: 14, }, buttonPressed: { opacity: 0.7, }, buttonText: { color: '#FFFFFF', textAlign: 'center', }, }); ``` React Native 样式使用 JavaScript 对象: - 使用 `backgroundColor`,不使用 `background-color`。 - 大多数数字不写 `px`,例如 `fontSize: 16`。 - 默认使用 Flex 布局,主轴方向默认为纵向。 - 文字必须写在 `Text` 组件中。 ## 点击和输入事件 网页常用 `onClick`,React Native 主要使用 `onPress`: ```tsx 点击我 ``` 不要直接调用事件函数: ```tsx // 正确:点击时执行 onPress={handlePress} // 正确:需要传参数 onPress={() => openEnterprise('1001')} // 错误:页面渲染时立即执行 onPress={handlePress()} ``` 常用事件: | 场景 | 事件 | |---|---| | 点击 | `onPress` | | 长按 | `onLongPress` | | 按下 | `onPressIn` | | 松开 | `onPressOut` | | 输入文字 | `onChangeText` | | 获得焦点 | `onFocus` | | 失去焦点 | `onBlur` | | 提交输入 | `onSubmitEditing` | | 滚动 | `onScroll` | | 下拉刷新 | `onRefresh` | | 列表滚到底 | `onEndReached` | 输入示例: ```tsx import { useState } from 'react'; import { TextInput } from 'react-native'; const [name, setName] = useState(''); ``` `onChangeText` 直接得到文本,不需要读取网页中的 `event.target.value`。 ## RNEUI UI 库 RNEUI 用于标准按钮、输入框、弹窗等通用控件。页面布局仍然优先使用 React Native 的 `View`、`Text`、`Pressable`。 当前项目为了集中处理 RNEUI 与 React 19 的类型兼容,通过 `components/ui/Rneui.tsx` 导出按钮和文字: ```tsx import { UiButton as Button, UiText as Text, } from '../../components/ui/Rneui'; 用户登录