feat(mobile): track mobile app scaffold

This commit is contained in:
caoxiaozhu
2026-05-22 12:41:45 +08:00
parent 222ba0bfdc
commit 1f15699013
79 changed files with 16854 additions and 0 deletions

43
mobile/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
mobile/app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mobile/app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

34
mobile/app/PROJECT.md Normal file
View File

@@ -0,0 +1,34 @@
# X-Financial Mobile 初始化说明
## 运行
```powershell
npm.cmd install --registry=https://registry.npmjs.org
npm.cmd run android
```
Android 模拟器访问本机后端时,默认 API 地址为:
```text
http://10.0.2.2:8000/api/v1
```
## 当前工程边界
- `src/app`Expo Router 页面入口已初始化首页、报销、审批、AI 助手、我的。
- `src/shared`API、登录态、状态映射、公共组件和 mock 数据。
- `src/platform`:相机、相册、语音输入等平台能力。
- `src/features`:后续承载复杂业务逻辑,避免页面文件继续膨胀。
## 已接入能力
- Android 权限:相机、麦克风、相册图片读取。
- 相机/相册:`platform/camera/receiptCapture.ts`
- 语音录制占位:`platform/voice/voiceInput.ts`
- 请求缓存:`@tanstack/react-query`
- 轻量状态:`zustand`
- 安全存储:`expo-secure-store`
## 开发原则
普通问答、票据预览和 AI 识别建议不自动保存草稿;只有用户明确点击保存、生成、继续提交或关联已有草稿,才进入持久化链路。

56
mobile/app/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
### Other setup steps
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

72
mobile/app/app.json Normal file
View File

@@ -0,0 +1,72 @@
{
"expo": {
"name": "X-Financial",
"slug": "x-financial-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "xfinancial",
"userInterfaceStyle": "light",
"ios": {
"icon": "./assets/expo.icon"
},
"android": {
"package": "com.xfinancial.mobile",
"permissions": [
"CAMERA",
"RECORD_AUDIO",
"READ_MEDIA_IMAGES"
],
"adaptiveIcon": {
"backgroundColor": "#EFFCF6",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": true
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "X-Financial 需要使用相机拍摄和识别报销票据。"
}
],
[
"expo-image-picker",
{
"photosPermission": "X-Financial 需要读取相册中的票据图片用于报销识别。"
}
],
[
"expo-splash-screen",
{
"backgroundColor": "#059669",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
[
"expo-audio",
{
"microphonePermission": "X-Financial 需要使用麦克风进行语音输入。"
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"apiBaseUrl": "http://10.0.2.2:8000/api/v1"
}
}
}

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);

13071
mobile/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
mobile/app/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "x-financial-mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"android:clear": "expo start --android --clear",
"android:clear": "expo start --android --clear",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"@tanstack/react-query": "^5.100.11",
"expo": "~55.0.26",
"expo-audio": "~55.0.14",
"expo-camera": "~55.0.19",
"expo-constants": "~55.0.16",
"expo-device": "~55.0.17",
"expo-file-system": "~55.0.22",
"expo-font": "~55.0.8",
"expo-glass-effect": "~55.0.11",
"expo-image": "~55.0.11",
"expo-image-picker": "~55.0.20",
"expo-linking": "~55.0.15",
"expo-router": "~55.0.16",
"expo-secure-store": "~55.0.14",
"expo-splash-screen": "~55.0.21",
"expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.9",
"expo-system-ui": "~55.0.18",
"expo-web-browser": "~55.0.16",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.6",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.4",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~55.0.1",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -0,0 +1,64 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Tabs } from 'expo-router';
import React from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/constants/theme';
const queryClient = new QueryClient();
type TabIconProps = {
color: string;
size: number;
};
function HomeIcon(props: TabIconProps) {
return <Ionicons name="home-outline" {...props} />;
}
function ClaimsIcon(props: TabIconProps) {
return <Ionicons name="document-text-outline" {...props} />;
}
function ApprovalsIcon(props: TabIconProps) {
return <Ionicons name="people-outline" {...props} />;
}
function AssistantIcon(props: TabIconProps) {
return <Ionicons name="sparkles-outline" {...props} />;
}
function ProfileIcon(props: TabIconProps) {
return <Ionicons name="person-outline" {...props} />;
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: Colors.light.primary,
tabBarInactiveTintColor: Colors.light.textSecondary,
tabBarStyle: {
height: 72,
paddingTop: 8,
paddingBottom: 10,
borderTopColor: Colors.light.border,
backgroundColor: '#FFFFFF',
},
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '800',
},
}}>
<Tabs.Screen name="index" options={{ title: '首页', tabBarIcon: HomeIcon }} />
<Tabs.Screen name="claims" options={{ title: '报销', tabBarIcon: ClaimsIcon }} />
<Tabs.Screen name="approvals" options={{ title: '审批', tabBarIcon: ApprovalsIcon }} />
<Tabs.Screen name="assistant" options={{ title: 'AI 助手', tabBarIcon: AssistantIcon }} />
<Tabs.Screen name="profile" options={{ title: '我的', tabBarIcon: ProfileIcon }} />
<Tabs.Screen name="explore" options={{ href: null }} />
</Tabs>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,64 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { approvalClaims } from '@/shared/mock/claims';
export default function ApprovalsScreen() {
return (
<Screen title="审批中心" subtitle="处理待我审批的报销单,查看 AI 风控提示后再做决定。">
<View style={styles.summary}>
<View>
<Text style={styles.summaryNumber}>{approvalClaims.length}</Text>
<Text style={styles.summaryLabel}></Text>
</View>
<View>
<Text style={styles.summaryNumber}>1</Text>
<Text style={styles.summaryLabel}></Text>
</View>
</View>
{approvalClaims.map((claim) => (
<View key={claim.id} style={styles.approvalItem}>
<ClaimCard claim={claim} />
<View style={styles.actionRow}>
<ActionButton accessibilityLabel={`驳回 ${claim.claimNo}`} variant="danger">
</ActionButton>
<ActionButton accessibilityLabel={`同意 ${claim.claimNo}`}></ActionButton>
</View>
</View>
))}
</Screen>
);
}
const styles = StyleSheet.create({
summary: {
flexDirection: 'row',
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
summaryNumber: {
color: Colors.light.primary,
fontSize: 26,
fontWeight: '900',
},
summaryLabel: {
color: Colors.light.textSecondary,
fontSize: 13,
},
approvalItem: {
gap: Spacing.three,
},
actionRow: {
flexDirection: 'row',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { captureReceiptFromCamera, pickReceiptFromLibrary } from '@/platform/camera/receiptCapture';
import { transcribeVoice, useVoiceRecorder } from '@/platform/voice/voiceInput';
export default function AssistantScreen() {
const [message, setMessage] = useState('');
const [receiptName, setReceiptName] = useState('');
const [isRecording, setIsRecording] = useState(false);
const voice = useVoiceRecorder();
async function handleCamera() {
try {
const receipt = await captureReceiptFromCamera();
if (receipt) {
setReceiptName(receipt.fileName);
}
} catch (error) {
Alert.alert('无法拍照', error instanceof Error ? error.message : '请检查相机权限。');
}
}
async function handleLibrary() {
try {
const receipt = await pickReceiptFromLibrary();
if (receipt) {
setReceiptName(receipt.fileName);
}
} catch (error) {
Alert.alert('无法选择票据', error instanceof Error ? error.message : '请检查相册权限。');
}
}
async function toggleVoice() {
try {
if (!isRecording) {
setIsRecording(true);
await voice.start();
return;
}
const uri = await voice.stop();
setIsRecording(false);
if (uri) {
const result = await transcribeVoice(uri);
setMessage(result.text);
}
} catch (error) {
setIsRecording(false);
Alert.alert('语音输入失败', error instanceof Error ? error.message : '请检查麦克风权限。');
}
}
return (
<Screen title="AI 助手" subtitle="输入问题、拍摄票据或使用语音描述费用,助手会返回可确认的报销建议。">
<View style={styles.hero}>
<Text style={styles.badge}>AI </Text>
<Text style={styles.title}></Text>
<Text style={styles.copy}></Text>
</View>
<View style={styles.answerCard}>
<Text style={styles.answerTitle}></Text>
<Text style={styles.answerText}>
86 320
</Text>
</View>
{receiptName ? (
<View style={styles.receiptBar}>
<Text style={styles.receiptText}>{receiptName}</Text>
</View>
) : null}
<View style={styles.inputCard}>
<TextInput
accessibilityLabel="助手输入框"
multiline
placeholder="描述费用或输入你的问题"
placeholderTextColor={Colors.light.textSecondary}
style={styles.input}
value={message}
onChangeText={setMessage}
/>
<View style={styles.actionRow}>
<ActionButton accessibilityLabel="拍照识别票据" variant="secondary" onPress={handleCamera}>
</ActionButton>
<ActionButton accessibilityLabel="从相册上传票据" variant="secondary" onPress={handleLibrary}>
</ActionButton>
<ActionButton accessibilityLabel="语音输入" variant={isRecording ? 'danger' : 'secondary'} onPress={toggleVoice}>
{isRecording ? '停止' : '语音'}
</ActionButton>
<ActionButton accessibilityLabel="发送给 AI 助手"></ActionButton>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
badge: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
backgroundColor: '#DFF8EC',
color: Colors.light.primary,
paddingHorizontal: 10,
paddingVertical: 4,
fontSize: 12,
fontWeight: '900',
},
title: {
color: Colors.light.text,
fontSize: 23,
fontWeight: '900',
},
copy: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 23,
},
answerCard: {
gap: Spacing.two,
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
answerTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
answerText: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 23,
},
receiptBar: {
borderRadius: Radius.md,
backgroundColor: '#E8F1FF',
padding: Spacing.three,
},
receiptText: {
color: Colors.light.info,
fontWeight: '800',
},
inputCard: {
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
input: {
minHeight: 92,
color: Colors.light.text,
fontSize: 15,
textAlignVertical: 'top',
},
actionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,48 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { myClaims } from '@/shared/mock/claims';
export default function ClaimsScreen() {
return (
<Screen title="我的报销" subtitle="查看草稿、审批中、已通过、已驳回和已付款的报销单。">
<View style={styles.searchBox}>
<Text style={styles.searchText}></Text>
</View>
<View style={styles.actionRow}>
<ActionButton accessibilityLabel="新建报销"></ActionButton>
<ActionButton accessibilityLabel="筛选报销单" variant="secondary">
</ActionButton>
</View>
{myClaims.map((claim) => (
<ClaimCard key={claim.id} claim={claim} />
))}
</Screen>
);
}
const styles = StyleSheet.create({
searchBox: {
minHeight: 48,
borderRadius: Radius.md,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: '#FFFFFF',
justifyContent: 'center',
paddingHorizontal: Spacing.four,
},
searchText: {
color: Colors.light.textSecondary,
fontSize: 14,
},
actionRow: {
flexDirection: 'row',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,181 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});

View File

@@ -0,0 +1,105 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { myClaims } from '@/shared/mock/claims';
export default function HomeScreen() {
return (
<Screen title="上午好,张三" subtitle="使用 AI 报销助手快速识别票据、生成草稿和跟踪审批。">
<View style={styles.hero}>
<Text style={styles.heroLabel}>AI </Text>
<Text style={styles.heroTitle}>AI </Text>
<Text style={styles.heroCopy}></Text>
<View style={styles.heroActions}>
<ActionButton accessibilityLabel="拍照识别票据"></ActionButton>
<ActionButton accessibilityLabel="上传票据" variant="secondary">
</ActionButton>
</View>
</View>
<View style={styles.quickGrid}>
<View style={styles.quickCard}>
<Text style={styles.quickTitle}></Text>
<Text style={styles.quickCopy}></Text>
</View>
<View style={styles.quickCard}>
<Text style={styles.quickTitle}></Text>
<Text style={styles.quickCopy}>2 </Text>
</View>
</View>
<Text style={styles.sectionTitle}></Text>
{myClaims.slice(0, 2).map((claim) => (
<ClaimCard key={claim.id} claim={claim} />
))}
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
gap: Spacing.three,
padding: Spacing.four,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: '#BFEEDB',
backgroundColor: Colors.light.primarySoft,
},
heroLabel: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
backgroundColor: '#DFF8EC',
color: Colors.light.primary,
paddingHorizontal: 10,
paddingVertical: 4,
fontSize: 12,
fontWeight: '900',
},
heroTitle: {
color: Colors.light.text,
fontSize: 21,
fontWeight: '900',
lineHeight: 29,
},
heroCopy: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
heroActions: {
flexDirection: 'row',
gap: Spacing.three,
},
quickGrid: {
flexDirection: 'row',
gap: Spacing.three,
},
quickCard: {
flex: 1,
gap: 5,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: '#FFFFFF',
padding: Spacing.four,
},
quickTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
quickCopy: {
color: Colors.light.textSecondary,
fontSize: 13,
},
sectionTitle: {
marginTop: Spacing.two,
color: Colors.light.text,
fontSize: 18,
fontWeight: '900',
},
});

View File

@@ -0,0 +1,92 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { useSessionStore } from '@/shared/auth/session';
export default function ProfileScreen() {
const user = useSessionStore((state) => state.user);
const signInAsDemoUser = useSessionStore((state) => state.signInAsDemoUser);
return (
<Screen title="我的" subtitle="查看当前移动端登录身份、角色和基础设置。">
<View style={styles.profileCard}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user?.displayName.slice(0, 1) || '未'}</Text>
</View>
<View style={styles.info}>
<Text style={styles.name}>{user?.displayName || '未登录'}</Text>
<Text style={styles.meta}>{user?.username || '-'}</Text>
<Text style={styles.meta}>{user?.roleCodes.join(', ') || '-'}</Text>
</View>
</View>
<View style={styles.settingCard}>
<Text style={styles.settingTitle}></Text>
<Text style={styles.settingText}>SecureStore </Text>
</View>
<ActionButton accessibilityLabel="恢复演示用户" onPress={signInAsDemoUser}>
</ActionButton>
</Screen>
);
}
const styles = StyleSheet.create({
profileCard: {
flexDirection: 'row',
gap: Spacing.four,
alignItems: 'center',
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
avatar: {
width: 60,
height: 60,
borderRadius: Radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.light.primarySoft,
},
avatarText: {
color: Colors.light.primary,
fontSize: 24,
fontWeight: '900',
},
info: {
flex: 1,
gap: 4,
},
name: {
color: Colors.light.text,
fontSize: 20,
fontWeight: '900',
},
meta: {
color: Colors.light.textSecondary,
fontSize: 13,
},
settingCard: {
gap: Spacing.two,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
settingTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
settingText: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
});

View File

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

View File

@@ -0,0 +1,132 @@
import { Image } from 'expo-image';
import { useState } from 'react';
import { Dimensions, StyleSheet, View } 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;
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) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.backgroundSolidColor}
/>
);
}
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.absoluteFillObject,
backgroundColor: '#208AEF',
zIndex: 1000,
},
});

View File

@@ -0,0 +1,108 @@
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',
},
});

View File

@@ -0,0 +1,33 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@@ -0,0 +1,116 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});

View File

@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -0,0 +1,35 @@
import React, { type ReactNode } from 'react';
import { View, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
type HintRowProps = {
title?: string;
hint?: ReactNode;
};
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
return (
<View style={styles.stepRow}>
<ThemedText type="small">{title}</ThemedText>
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
stepRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
codeSnippet: {
borderRadius: Spacing.two,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.two,
},
});

View File

@@ -0,0 +1,73 @@
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
themeColor?: ThemeColor;
};
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
const theme = useTheme();
return (
<Text
style={[
{ color: theme[themeColor ?? 'text'] },
type === 'default' && styles.default,
type === 'title' && styles.title,
type === 'small' && styles.small,
type === 'smallBold' && styles.smallBold,
type === 'subtitle' && styles.subtitle,
type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: 500,
},
smallBold: {
fontSize: 14,
lineHeight: 20,
fontWeight: 700,
},
default: {
fontSize: 16,
lineHeight: 24,
fontWeight: 500,
},
title: {
fontSize: 48,
fontWeight: 600,
lineHeight: 52,
},
subtitle: {
fontSize: 32,
lineHeight: 44,
fontWeight: 600,
},
link: {
lineHeight: 30,
fontSize: 14,
},
linkPrimary: {
lineHeight: 30,
fontSize: 14,
color: '#3c87f7',
},
code: {
fontFamily: Fonts.mono,
fontWeight: Platform.select({ android: 700 }) ?? 500,
fontSize: 12,
},
});

View File

@@ -0,0 +1,16 @@
import { View, type ViewProps } from 'react-native';
import { ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
type?: ThemeColor;
};
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
const theme = useTheme();
return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,65 @@
import { SymbolView } from 'expo-symbols';
import { PropsWithChildren, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<ThemedView>
<Pressable
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
onPress={() => setIsOpen((value) => !value)}>
<ThemedView type="backgroundElement" style={styles.button}>
<SymbolView
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
size={14}
weight="bold"
tintColor={theme.text}
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
/>
</ThemedView>
<ThemedText type="small">{title}</ThemedText>
</Pressable>
{isOpen && (
<Animated.View entering={FadeIn.duration(200)}>
<ThemedView type="backgroundElement" style={styles.content}>
{children}
</ThemedView>
</Animated.View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
pressedHeading: {
opacity: 0.7,
},
button: {
width: Spacing.four,
height: Spacing.four,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
content: {
marginTop: Spacing.three,
borderRadius: Spacing.three,
marginLeft: Spacing.four,
padding: Spacing.four,
},
});

View File

@@ -0,0 +1,44 @@
import { version } from 'expo/package.json';
import { Image } from 'expo-image';
import React from 'react';
import { useColorScheme, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
export function WebBadge() {
const scheme = useColorScheme();
return (
<ThemedView style={styles.container}>
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
v{version}
</ThemedText>
<Image
source={
scheme === 'dark'
? require('@/assets/images/expo-badge-white.png')
: require('@/assets/images/expo-badge.png')
}
style={styles.badgeImage}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.five,
alignItems: 'center',
gap: Spacing.two,
},
versionText: {
textAlign: 'center',
},
badgeImage: {
width: 123,
aspectRatio: 123 / 24,
},
});

View File

@@ -0,0 +1,80 @@
import '@/global.css';
import { Platform } from 'react-native';
export const Colors = {
light: {
text: '#071124',
background: '#F7FAFC',
backgroundElement: '#FFFFFF',
backgroundSelected: '#DFF8EC',
textSecondary: '#58677F',
primary: '#059669',
primarySoft: '#EFFCF6',
border: '#DBE5EF',
warning: '#F59E0B',
danger: '#EF4444',
info: '#2563EB',
},
dark: {
text: '#ffffff',
background: '#071124',
backgroundElement: '#111C2F',
backgroundSelected: '#143B35',
textSecondary: '#B0B8C8',
primary: '#34D399',
primarySoft: '#0F2F2A',
border: '#26364E',
warning: '#FBBF24',
danger: '#F87171',
info: '#60A5FA',
},
} as const;
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'var(--font-display)',
serif: 'var(--font-serif)',
rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)',
},
});
export const Spacing = {
half: 2,
one: 4,
two: 8,
three: 12,
four: 16,
five: 24,
six: 32,
seven: 48,
} as const;
export const Radius = {
sm: 6,
md: 8,
lg: 12,
pill: 999,
} as const;
export const BottomTabInset = Platform.select({ ios: 54, android: 76 }) ?? 0;
export const MaxContentWidth = 800;

View File

@@ -0,0 +1,11 @@
# X-Financial Mobile Features
本目录按业务功能拆分移动端页面和逻辑:
- `home`:首页聚合、待办、最近报销进度。
- `claims`:我的报销、新建报销、草稿、补材料。
- `approvals`:审批列表、审批详情、同意、驳回、转交。
- `assistant`AI 助手、语音输入、票据识别建议。
- `profile`:个人信息、角色、设置、退出登录。
初始化阶段页面入口仍放在 `src/app`,后续复杂业务逻辑优先下沉到对应 feature。

View File

@@ -0,0 +1,9 @@
:root {
--font-display:
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
}

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -0,0 +1,14 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useTheme() {
const scheme = useColorScheme();
const theme = scheme === 'unspecified' ? 'light' : scheme;
return Colors[theme];
}

View File

@@ -0,0 +1,10 @@
# Platform Layer
平台能力统一放在本目录,页面不直接绑定具体 Expo/原生库。
- `camera`:相机、相册、票据采集和本地预处理。
- `voice`:录音、语音转写、麦克风权限。
- `upload`:附件上传、进度、重试、临时附件引用。
- `permissions`Android / iOS 权限文案和降级策略。
相机与语音都先产生用户可确认的中间结果,不直接触发草稿持久化或提交审批。

View File

@@ -0,0 +1,55 @@
import * as ImagePicker from 'expo-image-picker';
export type CapturedReceipt = {
uri: string;
fileName: string;
mimeType?: string;
source: 'camera' | 'library';
};
function toReceipt(asset: ImagePicker.ImagePickerAsset, source: CapturedReceipt['source']): CapturedReceipt {
return {
uri: asset.uri,
fileName: asset.fileName || `receipt-${Date.now()}.jpg`,
mimeType: asset.mimeType,
source,
};
}
export async function captureReceiptFromCamera(): Promise<CapturedReceipt | null> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
throw new Error('需要相机权限才能拍摄票据。');
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
quality: 0.84,
mediaTypes: ['images'],
});
if (result.canceled || !result.assets[0]) {
return null;
}
return toReceipt(result.assets[0], 'camera');
}
export async function pickReceiptFromLibrary(): Promise<CapturedReceipt | null> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
throw new Error('需要相册权限才能选择票据图片。');
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
quality: 0.88,
mediaTypes: ['images'],
});
if (result.canceled || !result.assets[0]) {
return null;
}
return toReceipt(result.assets[0], 'library');
}

View File

@@ -0,0 +1,25 @@
import { useAudioRecorder, RecordingPresets } from 'expo-audio';
export function useVoiceRecorder() {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
return {
recorder,
async start() {
await recorder.prepareToRecordAsync();
recorder.record();
},
async stop() {
await recorder.stop();
return recorder.uri;
},
};
}
export async function transcribeVoice(uri: string) {
// 后续接入 /api/v1/mobile/voice/transcribe初始化阶段先返回可见占位结果。
return {
text: `已收到语音文件:${uri.split('/').pop() || 'voice-recording'}`,
confidence: 0,
};
}

View File

@@ -0,0 +1,11 @@
# Shared Layer
共享层承载跨 feature 的稳定能力:
- `api`:接口 client、OpenAPI 生成类型、请求错误映射。
- `auth`登录态、SecureStore、后端模拟身份请求头。
- `components`:可复用展示组件和触控组件。
- `domain`:报销状态、审批阶段、权限判断和 view model 映射。
- `mock`:初始化阶段的本地演示数据,接入后端后逐步替换。
业务状态判断优先放在 `domain`,避免 Web 和 mobile 对同一状态出现不同解释。

View File

@@ -0,0 +1,29 @@
import Constants from 'expo-constants';
type ApiOptions = RequestInit & {
authHeaders?: Record<string, string>;
};
const fallbackApiBaseUrl = 'http://10.0.2.2:8000/api/v1';
export const apiBaseUrl =
(Constants.expoConfig?.extra?.apiBaseUrl as string | undefined) || fallbackApiBaseUrl;
export async function apiRequest<T>(path: string, options: ApiOptions = {}): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...options.authHeaders,
...options.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(body || `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,51 @@
import * as SecureStore from 'expo-secure-store';
import { create } from 'zustand';
type UserSession = {
username: string;
displayName: string;
roleCodes: string[];
};
type SessionState = {
user: UserSession | null;
restore: () => Promise<void>;
signInAsDemoUser: () => Promise<void>;
signOut: () => Promise<void>;
};
const storageKey = 'x-financial-mobile-session';
const demoSession: UserSession = {
username: 'zhangsan',
displayName: '张三',
roleCodes: ['employee'],
};
export const useSessionStore = create<SessionState>((set) => ({
user: demoSession,
async restore() {
const raw = await SecureStore.getItemAsync(storageKey);
set({ user: raw ? (JSON.parse(raw) as UserSession) : demoSession });
},
async signInAsDemoUser() {
await SecureStore.setItemAsync(storageKey, JSON.stringify(demoSession));
set({ user: demoSession });
},
async signOut() {
await SecureStore.deleteItemAsync(storageKey);
set({ user: null });
},
}));
export function buildAuthHeaders(user: UserSession | null) {
if (!user) {
return {};
}
return {
'X-Auth-Username': user.username,
'X-Auth-Name': user.displayName,
'X-Auth-Role-Codes': user.roleCodes.join(','),
'X-Auth-Is-Admin': 'false',
};
}

View File

@@ -0,0 +1,60 @@
import type { PropsWithChildren } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { Colors, Radius, Spacing } from '@/constants/theme';
type Props = PropsWithChildren<{
onPress?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
accessibilityLabel: string;
}>;
export function ActionButton({ children, onPress, variant = 'primary', accessibilityLabel }: Props) {
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.button,
styles[variant],
pressed && styles.pressed,
]}>
<Text style={[styles.text, variant !== 'primary' && styles.secondaryText]}>{children}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
minHeight: 48,
borderRadius: Radius.md,
paddingHorizontal: Spacing.four,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
primary: {
backgroundColor: Colors.light.primary,
borderColor: Colors.light.primary,
},
secondary: {
backgroundColor: '#FFFFFF',
borderColor: Colors.light.border,
},
danger: {
backgroundColor: '#FFFFFF',
borderColor: Colors.light.danger,
},
text: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '900',
},
secondaryText: {
color: Colors.light.text,
},
pressed: {
opacity: 0.76,
},
});

View File

@@ -0,0 +1,91 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { formatMoney, type ClaimSummary } from '@/shared/domain/claim';
import { StatusPill } from '@/shared/components/StatusPill';
type Props = {
claim: ClaimSummary;
};
export function ClaimCard({ claim }: Props) {
return (
<View style={styles.card}>
<View style={styles.top}>
<View style={styles.iconBox}>
<Text style={styles.iconText}>{claim.title.slice(0, 1)}</Text>
</View>
<View style={styles.titleBlock}>
<Text style={styles.title}>{claim.title}</Text>
<Text style={styles.meta}>{claim.claimNo}</Text>
</View>
<Text style={styles.amount}>{formatMoney(claim.amount)}</Text>
</View>
<View style={styles.bottom}>
<View>
<Text style={styles.meta}>{claim.applicant}</Text>
<Text style={styles.meta}>{claim.approvalStage}</Text>
</View>
<StatusPill status={claim.status} />
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
gap: Spacing.four,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: Colors.light.backgroundElement,
padding: Spacing.four,
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 14,
elevation: 2,
},
top: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.three,
},
iconBox: {
width: 46,
height: 46,
borderRadius: Radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.light.primarySoft,
},
iconText: {
color: Colors.light.primary,
fontWeight: '900',
},
titleBlock: {
flex: 1,
gap: 3,
},
title: {
color: Colors.light.text,
fontSize: 17,
fontWeight: '900',
},
amount: {
color: Colors.light.text,
fontSize: 20,
fontWeight: '900',
},
bottom: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: Spacing.three,
},
meta: {
color: Colors.light.textSecondary,
fontSize: 13,
lineHeight: 21,
},
});

View File

@@ -0,0 +1,50 @@
import type { PropsWithChildren } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Colors, Spacing } from '@/constants/theme';
type Props = PropsWithChildren<{
title: string;
subtitle?: string;
}>;
export function Screen({ title, subtitle, children }: Props) {
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
{children}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: Colors.light.background,
},
content: {
gap: Spacing.four,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.four,
paddingBottom: 112,
},
header: {
gap: Spacing.two,
},
title: {
color: Colors.light.text,
fontSize: 28,
fontWeight: '900',
},
subtitle: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,44 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors, Radius } from '@/constants/theme';
import { claimStatusMeta, type ClaimStatus } from '@/shared/domain/claim';
const toneColors = {
success: { background: '#DFF8EC', text: '#047857' },
warning: { background: '#FFF3DC', text: '#B45309' },
danger: { background: '#FEE2E2', text: '#B91C1C' },
info: { background: '#E8F1FF', text: '#1D4ED8' },
muted: { background: '#EEF4F8', text: '#58677F' },
};
type Props = {
status: ClaimStatus;
};
export function StatusPill({ status }: Props) {
const meta = claimStatusMeta[status];
const tone = toneColors[meta.tone];
return (
<View style={[styles.pill, { backgroundColor: tone.background }]}>
<Text style={[styles.text, { color: tone.text }]}>{meta.label}</Text>
</View>
);
}
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
minHeight: 28,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Colors.light.border,
},
text: {
fontSize: 12,
fontWeight: '800',
},
});

View File

@@ -0,0 +1,39 @@
export type ClaimStatus =
| 'draft'
| 'pending_submission'
| 'manager_review'
| 'finance_review'
| 'returned'
| 'approved'
| 'paid'
| 'rejected';
export type ClaimAction = 'continue' | 'supplement' | 'submit' | 'approve' | 'return' | 'view';
export type ClaimSummary = {
id: string;
claimNo: string;
title: string;
amount: number;
applicant: string;
submittedAt: string;
status: ClaimStatus;
approvalStage: string;
canEdit: boolean;
canSupplement: boolean;
};
export const claimStatusMeta: Record<ClaimStatus, { label: string; tone: 'success' | 'warning' | 'danger' | 'info' | 'muted' }> = {
draft: { label: '草稿', tone: 'warning' },
pending_submission: { label: '待提交', tone: 'warning' },
manager_review: { label: '主管审批中', tone: 'success' },
finance_review: { label: '财务复核中', tone: 'info' },
returned: { label: '待补充', tone: 'warning' },
approved: { label: '已通过', tone: 'success' },
paid: { label: '已打款', tone: 'success' },
rejected: { label: '已驳回', tone: 'danger' },
};
export function formatMoney(value: number) {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}

View File

@@ -0,0 +1,67 @@
import type { ClaimSummary } from '@/shared/domain/claim';
export const myClaims: ClaimSummary[] = [
{
id: 'claim-travel-0422',
claimNo: 'REQ-2026-0422',
title: '差旅报销',
amount: 3280,
applicant: '张三',
submittedAt: '2026-05-03',
status: 'manager_review',
approvalStage: '主管审批中',
canEdit: false,
canSupplement: false,
},
{
id: 'claim-office-0431',
claimNo: 'REQ-2026-0431',
title: '办公采购',
amount: 458,
applicant: '张三',
submittedAt: '2026-05-01',
status: 'finance_review',
approvalStage: '财务复核',
canEdit: false,
canSupplement: false,
},
{
id: 'claim-business-0458',
claimNo: 'REQ-2026-0458',
title: '业务招待费',
amount: 860,
applicant: '张三',
submittedAt: '2026-04-30',
status: 'returned',
approvalStage: '部门经理审批',
canEdit: true,
canSupplement: true,
},
];
export const approvalClaims: ClaimSummary[] = [
{
id: 'approval-travel-0422',
claimNo: 'REQ-2026-0422',
title: '差旅报销',
amount: 3280,
applicant: '李四',
submittedAt: '2026-05-03',
status: 'manager_review',
approvalStage: '当前审批',
canEdit: false,
canSupplement: false,
},
{
id: 'approval-meal-0468',
claimNo: 'REQ-2026-0468',
title: '客户招待费',
amount: 1180,
applicant: '王五',
submittedAt: '2026-05-02',
status: 'manager_review',
approvalStage: '当前审批',
canEdit: false,
canSupplement: false,
},
];

4
mobile/app/src/types/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}

20
mobile/app/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
],
"@/assets/*": [
"./assets/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}